mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(gateway): push the branch before QA handoff so reviewers see the latest commits The commit content tool commits locally without pushing; only open_pr pushed the branch. On the first submission that was fine, but a fix committed while addressing needs_revision never reached origin (open_pr is skipped once the PR exists), so QA — which reviews the remote PR branch — re-reviewed the stale remote and re-failed the task on every cycle, a loop that never converged. i_am_done now pushes the task branch (idempotent; a no-op when nothing is unpushed) as part of the shared submit gate, covering both the normal and resume-from-verifying paths. A push failure blocks the handoff with a clear remediation rather than parking the task in awaiting_qa with commits that exist only in the developer's local workspace. * fix(orchestrator): don't reap a stale claim while the agent's container is alive The stale-claim reaper released any claimed/in_progress task whose last_heartbeat_at exceeded the TTL. The heartbeat only updates on certain gateway calls, so a developer deep in a long edit/test cycle outran the TTL and had its claim reaped mid-work — churning the task and risking a double spawn against the still-running container. The reaper now skips a task whose assignee still holds a live (ACTIVE) agent instance, trusting container liveness — the ground truth — over the heartbeat proxy. The check is defensive on missing fields so a heartbeat-only caller (and the reaper's existing unit tests) behave exactly as before. * fix(gateway): refuse to unblock a task while a dependency is unfinished A PM unblock on a dependency-gated task moved it straight to in_progress, overriding the dependency — letting a dependent proceed without its upstream's work (e.g. a frontend task built before its UX design lands). A dependency block is meant to clear on its own via _unblock_dependents the moment the upstream reaches a terminal state. unblock now refuses while any dependency is still non-terminal, returning a clear remediation that the block resolves automatically. Manual unblock remains available for genuine, non-dependency blockers. * fix(gateway): release a dependency-blocked claim to pending instead of looping A task that reached claimed/in_progress with an unfinished dependency was left in that state when the claim guard rejected, so the orchestrator's respawn loop kept reviving its assignee — which could make no progress — burning work for nothing. The claim guard now releases such a task back to pending. claimed -> blocked is not a legal transition, so pending — held by the dispatch dependency filter — is the lifecycle-correct resting state: the respawn loop ignores pending tasks, and _unblock_dependents re-dispatches it once the upstream reaches a terminal state. release_dependency_blocked_claim shares a _force_unclaim_to_pending core with unclaim_for_reaper so both record a truthful work-session abandon reason. * feat(security): warn at startup in header-trust mode + document the auth posture When ROBOCO_AGENT_AUTH_REQUIRED is not enabled the API accepts the X-Agent-Id / X-Agent-Role headers without a signed token, so any client that can reach it may act as any role (including 'ceo'). The API now logs a clear warning at startup in this mode, and the README gains a Security section documenting the auth posture and how to harden it. Acceptable only on a trusted private network — do not expose the API to untrusted networks. * fix(workspace): scope the refresh fetch to current + default branch ensure_workspace's healthy short-circuit ran an all-refs 'git fetch origin' to keep every origin/<branch> ref current. On a monorepo with many accumulated feature/* branches that exceeds the refresh timeout, the fetch silently fails, and the workspace keeps a stale base — so an agent builds on an out-of-date branch. The refresh now fetches only the workspace's current branch and the repo's default branch (resolved via origin/HEAD), with --no-tags --prune: it transfers near-nothing and can't time out. Readers need their own branch and the default; the integration branch is refreshed at branch-creation time. * fix(git): refresh a dependency-blocked task's branch off the current integration tip A cross-cell dependent (e.g. a frontend task waiting on the UX design) was branched off a base captured before its upstream merged into the integration branch, and the branch was never re-synced — so the agent built on a stale snapshot with none of the upstream's work. Two changes close the gap: - release_dependency_blocked_claim now clears branch_name, so the re-claim (after the dependency clears) re-runs branch creation. - create_branch, when the branch is already on disk with no commits of its own, resets it onto the freshly-pulled base — the dependent now builds on the current integration tip. A branch carrying real commits is left untouched, so no work is discarded; the cell->leaf cascade carries the upstream down to the dev branch automatically. * refactor(gateway): drop the sibling-sequence claim guard Sibling sequence no longer gates a claim. Cross-cell ordering is enforced by task dependencies — a cell task that depends on another is held until its upstream reaches a terminal state, a stronger, status-aware gate than the sequence-number check. That check was dormant in practice anyway: every fan-out child carries sequence 0, on which the guard short-circuited. `sequence` stays a sibling-ordering / dispatch-priority field (list_pending ordering and the panel). Removes sibling_sequence_guard and its _earlier_blocking_sibling helper, the now-unused skip_sequence parameter threaded through the claim verbs, and the sibling fetch that fed it. * feat(gateway): sort a cross-cell dependent after its upstream When the frontend cell task is wired to depend on its UX/UI sibling, set its sequence to the upstream's sequence + 1 so it sorts after the design it waits on — list_pending ordering and the panel now show UX ahead of the implementation it gates, in either delegation order. Adds TaskService.set_sequence (the sibling-ordering field is a service write; it carries no claim-gating semantics — dependencies gate claims). * feat(gateway): make the backend cell depend on UX too UX/UI design defines the screens and API contracts both implementation cells build against, so the backend cell — not just the frontend — waits on the UX/UI cell task in a product fan-out and sorts after it. Wires in either delegation order: a backend task delegated after UX gets the dependency directly; a UX task delegated after a still-pending backend sibling retro-wires it. Mirrors the existing frontend wiring (_depend_backend_on_ux and _depend_pending_backends_on_ux). Backend is held by the same dependency gate, so it costs no extra dispatch churn. * fix(websocket): forward notification acks instead of logging them incomplete The bridge handler serves both notification.sent and notification.acked, but acked events carry `agent_id` (the acking agent) rather than `recipient_id`, so every acknowledgement tripped the missing-field guard and logged "Incomplete notification event" instead of reaching the panel. Accept either field as the recipient. * feat(api): hint the full UUID when a truncated task id fails validation Agents copy the 8-character task prefix the system shows them (the commit prefix, task summaries) and send it as task_id, which fails UUID validation with an opaque "invalid length" 422 and wastes a call. The request-validation handler now detects a task_id UUID error and attaches a `remediate` hint telling the agent to retry with the full 36-character UUID from its task envelope. * fix(audit): record the blocked transition when a task is escalated Escalation sets a task to blocked by writing task.status directly, which bypassed the validated transition helper and so never emitted a task.blocked audit row — the lifecycle moved but the Auditor saw nothing. Extract the audit emit from the central transition helper into _emit_status_transition_audit and call it from the escalate path, capturing the prior status and outgoing owner before reassignment so the row is attributed correctly. * fix(docs): stop doubling the docs path so design specs index into RAG The documenter sometimes hands a doc path already rooted at docs/, and joining it onto DOCS_BASE_PATH (/app/docs) produced /app/docs/docs/..., so the file was never found and the spec never indexed — the frontend cell could not retrieve the UX design over RAG. Normalize the path before joining: trust an absolute path, otherwise strip a single redundant leading docs/ segment. * feat(security): let the control panel authenticate in secure mode With ROBOCO_AGENT_AUTH_REQUIRED=true every request must carry a valid HMAC token, which locked the human control panel out — it sends role headers but no token. nginx, the only trusted hop between the browser and the API, now injects the CEO token on /api and /ws, so the browser never holds the signing secret. The injected value is just the existing per-agent token issued for the CEO identity (issue_panel_token), so the token-verification path is unchanged. An empty value (dev/header-trust mode) renders to no header. `make panel-token` prints the value; set it as ROBOCO_PANEL_AGENT_TOKEN in .env before enabling secure mode. .env.example and the README Security section document the flow. * chore(compose): consolidate the two compose files into one docker-compose.yml and docker-compose.yaml had diverged: .yml — the file Docker actually uses — carried ROBOCO_PUBLIC_BASE_URL but was missing the /app/manifests bind-mount, while .yaml had the manifests mount but not the base URL. Merge the union into docker-compose.yml and delete the duplicate so there is one source of truth and no "multiple config files" warning. This activates the manifests mount in the deployed file: without it the orchestrator writes per-agent tool manifests to its ephemeral container fs, they never reach the host for the daemon to bind-mount, and agents fall back to all-verbs registration. Drop the stale .yaml reference from the config.py docstring, the labeler, and the CI path filters. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
692 lines
23 KiB
Python
692 lines
23 KiB
Python
"""
|
|
Agent Configuration
|
|
|
|
Single source of truth for agent roles, teams, and cell memberships.
|
|
All enforcement modules and MCP servers should import from here.
|
|
|
|
PERMISSION ARCHITECTURE
|
|
-----------------------
|
|
The system uses TWO complementary permission layers:
|
|
|
|
1. MCP Layer (this module - agents_config.py):
|
|
- Controls which MCP tools agents can access
|
|
- Uses role-based helper functions: can_create_tasks(), can_cancel_tasks(), etc.
|
|
- Works with agent slugs/UUIDs directly
|
|
- Determines tool visibility at MCP server registration time
|
|
|
|
2. API Layer (roboco/services/permissions.py):
|
|
- Controls fine-grained API endpoint access
|
|
- Uses TaskAction enum and PermissionService class
|
|
- Works with AgentContext and Team enums
|
|
- Validates at request time with team-scoped checks
|
|
|
|
Both layers derive from the same source data (role definitions here) but serve
|
|
different purposes. MCP is coarse-grained (tool-level), API is fine-grained
|
|
(action + team context).
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
from typing import Final
|
|
|
|
from roboco.foundation import identity as _foundation
|
|
from roboco.foundation.policy import communications as _comms
|
|
from roboco.models.base import NotificationPriority, NotificationType
|
|
from roboco.seeds.initial_data import AGENT_UUIDS, CEO_AGENT_ID
|
|
|
|
# Env var containing the HMAC secret used to sign agent auth tokens.
|
|
# Must be set in orchestrator + API container environments; if missing,
|
|
# token verification refuses every token (fail-closed). Generate with:
|
|
# python -c 'import secrets; print(secrets.token_hex(32))'
|
|
_AUTH_SECRET_ENV: Final[str] = "ROBOCO_AGENT_AUTH_SECRET"
|
|
|
|
|
|
def _auth_secret() -> bytes | None:
|
|
"""Return the HMAC secret bytes, or None when unset."""
|
|
v = os.environ.get(_AUTH_SECRET_ENV, "")
|
|
return v.encode("utf-8") if v else None
|
|
|
|
|
|
def _signing_payload(agent_id: str, role: str, team: str) -> bytes:
|
|
"""Canonical message for HMAC — all inputs lower-cased and stripped."""
|
|
parts = (
|
|
(agent_id or "").strip().lower(),
|
|
(role or "").strip().lower(),
|
|
(team or "").strip().lower(),
|
|
)
|
|
return ":".join(parts).encode("utf-8")
|
|
|
|
|
|
def issue_agent_token(agent_id: str, role: str, team: str = "") -> str:
|
|
"""Mint an auth token the orchestrator injects into an agent's env.
|
|
|
|
The token is a hex HMAC-SHA256 of `agent_id:role:team` signed with
|
|
ROBOCO_AGENT_AUTH_SECRET. It binds the agent's identity to the role
|
|
and team headers — if the agent later lies about its role, the
|
|
server-side HMAC won't match.
|
|
"""
|
|
secret = _auth_secret()
|
|
if not secret:
|
|
# Unset secret ⇒ tokens are meaningless; return a sentinel the
|
|
# verifier will reject. Caller should detect and log this.
|
|
return "UNSIGNED"
|
|
return hmac.new(
|
|
secret, _signing_payload(agent_id, role, team), hashlib.sha256
|
|
).hexdigest()
|
|
|
|
|
|
def verify_agent_token(token: str, agent_id: str, role: str, team: str = "") -> bool:
|
|
"""Return True iff `token` is a valid HMAC for (agent_id, role, team).
|
|
|
|
Fails closed when the secret is unset or the token is the UNSIGNED
|
|
sentinel.
|
|
"""
|
|
secret = _auth_secret()
|
|
if not secret or not token or token == "UNSIGNED":
|
|
return False
|
|
expected = hmac.new(
|
|
secret, _signing_payload(agent_id, role, team), hashlib.sha256
|
|
).hexdigest()
|
|
return hmac.compare_digest(expected, token)
|
|
|
|
|
|
def issue_panel_token() -> str:
|
|
"""Mint the token the control panel presents to act as the CEO.
|
|
|
|
The panel calls the API as the CEO identity — ``X-Agent-Id`` = the CEO
|
|
UUID, ``X-Agent-Role`` = ``ceo``, and no team header — so the token is
|
|
signed for exactly those values (empty team). In secure mode nginx injects
|
|
it as ``X-Agent-Token`` so the browser never holds the signing secret;
|
|
this is just the existing per-agent token issued for the CEO identity, so
|
|
the verification path is unchanged. Returns ``UNSIGNED`` when the secret is
|
|
unset (same fail-closed contract as ``issue_agent_token``).
|
|
"""
|
|
return issue_agent_token(CEO_AGENT_ID, "ceo", "")
|
|
|
|
|
|
# Reverse mapping: UUID -> slug (computed from seeds)
|
|
_UUID_TO_SLUG: Final[dict[str, str]] = {
|
|
uuid: slug for slug, uuid in AGENT_UUIDS.items()
|
|
}
|
|
|
|
|
|
def _resolve_to_slug(agent_id: str) -> str:
|
|
"""Resolve agent ID (UUID or slug) to slug."""
|
|
return _UUID_TO_SLUG.get(agent_id, agent_id)
|
|
|
|
|
|
# =============================================================================
|
|
# AGENT ROLE MAPPINGS
|
|
# =============================================================================
|
|
|
|
# Agent catalog data is canonicalized in roboco/foundation/identity.py.
|
|
# These string-keyed maps are kept for backwards compatibility with code
|
|
# that types role/team as `str` rather than the foundation enums.
|
|
# Derived at module load — adding an agent edits foundation/identity.py only.
|
|
AGENT_ROLE_MAP: dict[str, str] = {
|
|
slug: row.role.value
|
|
for slug, row in _foundation.AGENTS.items()
|
|
if row.role != _foundation.Role.SYSTEM # exclude sentinel from string-keyed map
|
|
}
|
|
|
|
AGENT_TEAM_MAP: dict[str, str] = {
|
|
slug: row.team.value
|
|
for slug, row in _foundation.AGENTS.items()
|
|
if row.role != _foundation.Role.SYSTEM
|
|
}
|
|
|
|
CELL_MEMBERS: dict[str, list[str]] = {
|
|
team.value: sorted(_foundation.slugs_for_team(team))
|
|
for team in sorted(_foundation.CELL_TEAMS, key=lambda t: t.value)
|
|
}
|
|
|
|
|
|
# All agent IDs
|
|
ALL_AGENTS: Final[list[str]] = list(AGENT_ROLE_MAP.keys())
|
|
|
|
# Board members
|
|
BOARD_MEMBERS: Final[list[str]] = ["product-owner", "head-marketing", "auditor"]
|
|
|
|
# All documenters (cross-cell) — used for docs-write workspace permissions
|
|
ALL_DOCS: Final[list[str]] = ["be-doc", "fe-doc", "ux-doc"]
|
|
|
|
# `PM_ROLES` is canonical in foundation.identity (CELL_PM + MAIN_PM only).
|
|
# This file's historical 5-role set is renamed to TASK_CREATOR_ROLES — it
|
|
# represents "roles that can call task.create", not the PM hierarchy.
|
|
# StrEnum members hash like their .value strings, so `role_str in TASK_CREATOR_ROLES`
|
|
# still works for str inputs from get_agent_role().
|
|
TASK_CREATOR_ROLES: Final[frozenset[_foundation.Role]] = frozenset(
|
|
{
|
|
_foundation.Role.CELL_PM,
|
|
_foundation.Role.MAIN_PM,
|
|
_foundation.Role.PRODUCT_OWNER,
|
|
_foundation.Role.HEAD_MARKETING,
|
|
_foundation.Role.CEO,
|
|
}
|
|
)
|
|
|
|
# Escalation chain - who each agent escalates to
|
|
ESCALATION_CHAIN: Final[dict[str, str]] = {
|
|
# Developers → Cell PM
|
|
"be-dev-1": "be-pm",
|
|
"be-dev-2": "be-pm",
|
|
"fe-dev-1": "fe-pm",
|
|
"fe-dev-2": "fe-pm",
|
|
"ux-dev-1": "ux-pm",
|
|
"ux-dev-2": "ux-pm",
|
|
# QA → Cell PM
|
|
"be-qa": "be-pm",
|
|
"fe-qa": "fe-pm",
|
|
"ux-qa": "ux-pm",
|
|
# Documenters → Cell PM
|
|
"be-doc": "be-pm",
|
|
"fe-doc": "fe-pm",
|
|
"ux-doc": "ux-pm",
|
|
# Cell PM → Main PM
|
|
"be-pm": "main-pm",
|
|
"fe-pm": "main-pm",
|
|
"ux-pm": "main-pm",
|
|
# Main PM → Product Owner
|
|
"main-pm": "product-owner",
|
|
# Product Owner → CEO (final escalation)
|
|
"product-owner": "ceo",
|
|
"head-marketing": "ceo",
|
|
"auditor": "ceo",
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# HELPER FUNCTIONS
|
|
# =============================================================================
|
|
|
|
|
|
def get_agent_role(agent_id: str) -> str:
|
|
"""Get the role for an agent. Accepts both UUID and slug."""
|
|
slug = _resolve_to_slug(agent_id)
|
|
return AGENT_ROLE_MAP.get(slug, "unknown")
|
|
|
|
|
|
def get_agent_team(agent_id: str) -> str | None:
|
|
"""Get the team for an agent. Accepts both UUID and slug."""
|
|
slug = _resolve_to_slug(agent_id)
|
|
return AGENT_TEAM_MAP.get(slug)
|
|
|
|
|
|
def get_agent_cell(agent_id: str) -> str | None:
|
|
"""Get the cell an agent belongs to. Accepts both UUID and slug."""
|
|
return get_agent_team(agent_id)
|
|
|
|
|
|
def get_cell_members(cell: str) -> list[str]:
|
|
"""Get all members of a cell."""
|
|
return CELL_MEMBERS.get(cell, [])
|
|
|
|
|
|
def is_pm(agent_id: str) -> bool:
|
|
"""Check if agent is a PM (cell PM or main PM)."""
|
|
role = get_agent_role(agent_id)
|
|
return role in ("cell_pm", "main_pm")
|
|
|
|
|
|
def is_board_member(agent_id: str) -> bool:
|
|
"""Check if agent is a board member."""
|
|
return agent_id in BOARD_MEMBERS
|
|
|
|
|
|
def is_management(agent_id: str) -> bool:
|
|
"""Check if agent is in management (PM, Board, CEO)."""
|
|
role = get_agent_role(agent_id)
|
|
return role in (
|
|
"cell_pm",
|
|
"main_pm",
|
|
"product_owner",
|
|
"head_marketing",
|
|
"auditor",
|
|
"ceo",
|
|
)
|
|
|
|
|
|
def is_ceo(agent_id: str) -> bool:
|
|
"""Check if agent is CEO (has full bypass on all permissions)."""
|
|
return get_agent_role(agent_id) == "ceo"
|
|
|
|
|
|
def can_send_notifications(agent_id: str) -> bool:
|
|
"""Whether this agent's role may call notify(). Canonical in foundation."""
|
|
try:
|
|
return _foundation.Role(get_agent_role(agent_id)) in _comms.NOTIFY_SENDER_ROLES
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def can_create_tasks(agent_id: str) -> bool:
|
|
"""Check if agent can create tasks (PMs, board, and CEO)."""
|
|
role = get_agent_role(agent_id)
|
|
return role in TASK_CREATOR_ROLES
|
|
|
|
|
|
def can_assign_tasks(agent_id: str) -> bool:
|
|
"""Check if agent can assign tasks (PMs, board, and CEO)."""
|
|
role = get_agent_role(agent_id)
|
|
return role in TASK_CREATOR_ROLES
|
|
|
|
|
|
# Cancel roles match task_lifecycle.py - CEO and Auditor cannot cancel (they observe)
|
|
_CANCEL_ROLES: Final[set[str]] = {
|
|
"cell_pm",
|
|
"main_pm",
|
|
"product_owner",
|
|
"head_marketing",
|
|
}
|
|
|
|
|
|
def can_cancel_tasks(agent_id: str) -> bool:
|
|
"""Check if agent can cancel tasks (PMs and board, not CEO/Auditor)."""
|
|
role = get_agent_role(agent_id)
|
|
return role in _CANCEL_ROLES
|
|
|
|
|
|
def get_escalation_target(agent_id: str) -> str | None:
|
|
"""Get the escalation target for an agent."""
|
|
return ESCALATION_CHAIN.get(agent_id)
|
|
|
|
|
|
def get_pm_for_team(team: str) -> str | None:
|
|
"""Get the cell PM for a team."""
|
|
team_to_pm = {
|
|
"backend": "be-pm",
|
|
"frontend": "fe-pm",
|
|
"ux_ui": "ux-pm",
|
|
}
|
|
return team_to_pm.get(team)
|
|
|
|
|
|
def get_pm_for_agent(agent_id: str) -> str | None:
|
|
"""
|
|
Get the PM responsible for an agent.
|
|
|
|
- For cell members: their cell PM
|
|
- For cell PMs: main-pm
|
|
- For main PM: product-owner
|
|
"""
|
|
role = get_agent_role(agent_id)
|
|
|
|
# Cell PM escalates to main-pm
|
|
if role == "cell_pm":
|
|
return "main-pm"
|
|
|
|
# Main PM escalates to product-owner
|
|
if role == "main_pm":
|
|
return "product-owner"
|
|
|
|
# Everyone else escalates to their cell PM
|
|
return get_escalation_target(agent_id)
|
|
|
|
|
|
# =============================================================================
|
|
# CHANNEL ACCESS RULES
|
|
# =============================================================================
|
|
#
|
|
# Channel ACL is canonicalized in foundation.policy.communications.CHANNELS.
|
|
# This slug-keyed dict-of-string-lists derives from the role-keyed foundation
|
|
# data. Adding a channel or changing its membership edits foundation.CHANNELS;
|
|
# this dict updates at module load.
|
|
#
|
|
# Derivation rules:
|
|
# - read: roles in (read_roles - silent_roles), filtered by team_scope
|
|
# - write: roles in write_roles, filtered by team_scope
|
|
# - silent: roles in silent_roles, filtered by team_scope
|
|
# Cross-cell roles (MAIN_PM, AUDITOR, CEO, board) are not subject to team_scope;
|
|
# only cell-member roles (DEVELOPER/QA/DOCUMENTER/CELL_PM) are filtered.
|
|
|
|
# Cell-member roles subject to team_scope filtering. Lifted to module scope so
|
|
# tests and downstream consumers can introspect the rule.
|
|
_TEAM_SCOPED_ROLES: Final[frozenset[_foundation.Role]] = frozenset(
|
|
{
|
|
_foundation.Role.DEVELOPER,
|
|
_foundation.Role.QA,
|
|
_foundation.Role.DOCUMENTER,
|
|
_foundation.Role.CELL_PM,
|
|
}
|
|
)
|
|
|
|
|
|
def _slugs_for_role_set(
|
|
role_set: frozenset[_foundation.Role],
|
|
team_scope: _foundation.Team | None,
|
|
) -> list[str]:
|
|
"""Expand a role-set to sorted agent slugs, honoring optional team_scope.
|
|
|
|
A slug qualifies when its role is in `role_set` AND, if its role is in
|
|
_TEAM_SCOPED_ROLES and team_scope is set, its team matches team_scope.
|
|
The system sentinel is always excluded.
|
|
"""
|
|
out: list[str] = []
|
|
for slug, row in _foundation.AGENTS.items():
|
|
if slug == "system":
|
|
continue
|
|
if row.role not in role_set:
|
|
continue
|
|
if (
|
|
team_scope is not None
|
|
and row.role in _TEAM_SCOPED_ROLES
|
|
and row.team != team_scope
|
|
):
|
|
continue
|
|
out.append(slug)
|
|
return sorted(out)
|
|
|
|
|
|
CHANNEL_ACCESS: Final[dict[str, dict[str, list[str]]]] = {
|
|
slug: {
|
|
"read": _slugs_for_role_set(
|
|
spec.read_roles - spec.silent_roles, spec.team_scope
|
|
),
|
|
"write": _slugs_for_role_set(spec.write_roles, spec.team_scope),
|
|
"silent": _slugs_for_role_set(spec.silent_roles, spec.team_scope),
|
|
}
|
|
for slug, spec in _comms.CHANNELS.items()
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# NOTIFICATION PERMISSIONS
|
|
# =============================================================================
|
|
|
|
# =============================================================================
|
|
# PERMISSION LEVEL HIERARCHY
|
|
# =============================================================================
|
|
|
|
# Maps role strings to permission level names
|
|
# This is the SINGLE SOURCE OF TRUTH for role hierarchy
|
|
# Used by PermissionService to build AgentRole -> PermissionLevel mapping
|
|
ROLE_PERMISSION_LEVELS: Final[dict[str, str]] = {
|
|
"system": "CEO", # System/orchestrator has CEO-level access for internal operations
|
|
"ceo": "CEO",
|
|
"product_owner": "BOARD",
|
|
"head_marketing": "BOARD",
|
|
"auditor": "AUDITOR",
|
|
"main_pm": "MAIN_PM",
|
|
"cell_pm": "CELL_PM",
|
|
"developer": "CELL_MEMBER",
|
|
"qa": "CELL_MEMBER",
|
|
"documenter": "CELL_MEMBER",
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# NOTIFICATION PERMISSIONS
|
|
# =============================================================================
|
|
# Sender allowlist now lives in foundation.policy.communications.NOTIFY_SENDER_ROLES.
|
|
# Scope rules (cell / all / list) live in services/permissions.py since they
|
|
# depend on AgentContext (role + team) — not pure foundation data.
|
|
|
|
VALID_NOTIFICATION_TYPES: Final[frozenset[str]] = frozenset(
|
|
t.value for t in NotificationType
|
|
)
|
|
VALID_NOTIFICATION_PRIORITIES: Final[frozenset[str]] = frozenset(
|
|
p.value for p in NotificationPriority
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# A2A AGENT SKILLS (for Agent Cards)
|
|
# =============================================================================
|
|
|
|
# Skills define what each role can do - used for A2A discovery
|
|
ROLE_SKILLS: Final[dict[str, list[dict[str, str | list[str]]]]] = {
|
|
"developer": [
|
|
{
|
|
"id": "code_implementation",
|
|
"name": "Code Implementation",
|
|
"description": "Implement features, fix bugs, write production code",
|
|
"tags": ["coding", "implementation", "bugfix"],
|
|
},
|
|
{
|
|
"id": "code_review",
|
|
"name": "Code Review",
|
|
"description": "Review code changes and provide feedback",
|
|
"tags": ["review", "feedback"],
|
|
},
|
|
{
|
|
"id": "technical_research",
|
|
"name": "Technical Research",
|
|
"description": "Research technical solutions and approaches",
|
|
"tags": ["research", "analysis"],
|
|
},
|
|
],
|
|
"qa": [
|
|
{
|
|
"id": "code_review",
|
|
"name": "Code Review",
|
|
"description": "Review code for bugs, security issues, and quality",
|
|
"tags": ["review", "quality", "security"],
|
|
},
|
|
{
|
|
"id": "test_validation",
|
|
"name": "Test Validation",
|
|
"description": "Validate test coverage and test quality",
|
|
"tags": ["testing", "validation"],
|
|
},
|
|
{
|
|
"id": "security_audit",
|
|
"name": "Security Audit",
|
|
"description": "Audit code for security vulnerabilities",
|
|
"tags": ["security", "audit"],
|
|
},
|
|
],
|
|
"documenter": [
|
|
{
|
|
"id": "documentation",
|
|
"name": "Documentation",
|
|
"description": "Create and maintain documentation",
|
|
"tags": ["docs", "writing"],
|
|
},
|
|
{
|
|
"id": "handoff_review",
|
|
"name": "Handoff Review",
|
|
"description": "Review and document task handoffs",
|
|
"tags": ["handoff", "review"],
|
|
},
|
|
],
|
|
"cell_pm": [
|
|
{
|
|
"id": "task_management",
|
|
"name": "Task Management",
|
|
"description": "Create, assign, and manage tasks within the cell",
|
|
"tags": ["planning", "coordination"],
|
|
},
|
|
{
|
|
"id": "blocker_resolution",
|
|
"name": "Blocker Resolution",
|
|
"description": "Help resolve blockers and coordinate resources",
|
|
"tags": ["support", "coordination"],
|
|
},
|
|
{
|
|
"id": "qa_coordination",
|
|
"name": "QA Coordination",
|
|
"description": "Coordinate QA reviews and approvals",
|
|
"tags": ["qa", "approval"],
|
|
},
|
|
],
|
|
"main_pm": [
|
|
{
|
|
"id": "task_triage",
|
|
"name": "Task Triage",
|
|
"description": "Triage and distribute tasks to cell PMs",
|
|
"tags": ["triage", "distribution"],
|
|
},
|
|
{
|
|
"id": "cross_cell_coordination",
|
|
"name": "Cross-Cell Coordination",
|
|
"description": "Coordinate work across multiple cells",
|
|
"tags": ["coordination", "cross-team"],
|
|
},
|
|
{
|
|
"id": "escalation_handling",
|
|
"name": "Escalation Handling",
|
|
"description": "Handle escalated issues from cell PMs",
|
|
"tags": ["escalation", "support"],
|
|
},
|
|
],
|
|
"product_owner": [
|
|
{
|
|
"id": "requirements_clarification",
|
|
"name": "Requirements Clarification",
|
|
"description": "Clarify product requirements and priorities",
|
|
"tags": ["requirements", "product"],
|
|
},
|
|
{
|
|
"id": "feature_approval",
|
|
"name": "Feature Approval",
|
|
"description": "Approve feature implementations",
|
|
"tags": ["approval", "product"],
|
|
},
|
|
],
|
|
"head_marketing": [
|
|
{
|
|
"id": "market_analysis",
|
|
"name": "Market Analysis",
|
|
"description": "Provide market context and analysis",
|
|
"tags": ["marketing", "analysis"],
|
|
},
|
|
],
|
|
"auditor": [
|
|
{
|
|
"id": "quality_audit",
|
|
"name": "Quality Audit",
|
|
"description": "Audit quality and compliance",
|
|
"tags": ["audit", "quality"],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def get_agent_skills(agent_id: str) -> list[dict]:
|
|
"""Get A2A skills for an agent based on their role."""
|
|
role = get_agent_role(agent_id)
|
|
return list(ROLE_SKILLS.get(role, []))
|
|
|
|
|
|
# =============================================================================
|
|
# A2A PERMISSION ENFORCEMENT
|
|
# =============================================================================
|
|
# A2A follows the same hierarchy as escalations and notifications:
|
|
# - Within cell: Direct A2A allowed
|
|
# - Cross-cell: Must go through Cell PM → Main PM
|
|
# - To board: Must go through Main PM
|
|
# - To CEO: Must go through board
|
|
|
|
# Board roles (PO + Head Marketing + Auditor) — derived from foundation.
|
|
# Main PM is intentionally NOT in this set; main_pm is a layer above cells
|
|
# but below the board.
|
|
_BOARD_ROLES: Final[frozenset[_foundation.Role]] = _foundation.BOARD_ROLES
|
|
_MAIN_PM_TARGETS: Final[frozenset[str]] = frozenset(
|
|
{"cell_pm", "main_pm", "product_owner", "head_marketing", "auditor"}
|
|
)
|
|
|
|
|
|
def _check_cell_pm_a2a(
|
|
from_team: str | None, to_agent: str, to_role: str, to_team: str | None
|
|
) -> tuple[bool, str | None]:
|
|
"""Check A2A permissions for cell PM."""
|
|
# Own cell, other PMs, or main-pm
|
|
if to_team == from_team or to_role in ("cell_pm", "main_pm"):
|
|
return True, None
|
|
# Board/CEO - escalate
|
|
if to_role in _BOARD_ROLES:
|
|
return False, f"Cell PMs cannot A2A {to_role}. Escalate through main-pm."
|
|
# Other cell members
|
|
return False, f"Cannot A2A {to_agent} (different cell). Use main-pm."
|
|
|
|
|
|
def _check_cell_member_a2a(
|
|
from_agent: str, from_team: str, to_agent: str, to_role: str, to_team: str | None
|
|
) -> tuple[bool, str | None]:
|
|
"""Check A2A permissions for cell members (dev, qa, doc)."""
|
|
cell_pm = get_pm_for_team(from_team)
|
|
# Same cell - allowed
|
|
if to_team == from_team:
|
|
return True, None
|
|
# Cross-cell
|
|
if to_team:
|
|
target_pm = get_pm_for_team(to_team)
|
|
return (
|
|
False,
|
|
f"Cannot A2A {to_agent} (cell: {to_team}). "
|
|
f"Ask {cell_pm} to coordinate with {target_pm}.",
|
|
)
|
|
# Management - not direct
|
|
return False, f"Cannot A2A {to_role}. Route: {from_agent} → {cell_pm} → main-pm."
|
|
|
|
|
|
def _check_main_pm_a2a(to_role: str, to_team: str | None) -> tuple[bool, str | None]:
|
|
"""Check A2A permissions for main PM."""
|
|
if to_role in _MAIN_PM_TARGETS:
|
|
return True, None
|
|
pm = get_pm_for_team(to_team) if to_team else "cell-pm"
|
|
return False, f"Main PM cannot A2A {to_role}s. Route through {pm or 'cell-pm'}."
|
|
|
|
|
|
def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
|
|
"""
|
|
Check if from_agent can send A2A directly to to_agent.
|
|
|
|
Returns (allowed, error_message). Error explains who to contact instead.
|
|
"""
|
|
from_role = get_agent_role(from_agent)
|
|
to_role = get_agent_role(to_agent)
|
|
from_team = get_agent_team(from_agent)
|
|
to_team = get_agent_team(to_agent)
|
|
|
|
# CEO is human - cannot A2A, use notifications
|
|
if to_role == "ceo":
|
|
return False, "CEO is human. Use notify() instead of A2A."
|
|
|
|
# Board → board/main-pm (not CEO, not cells directly)
|
|
if from_role in ("product_owner", "head_marketing", "auditor"):
|
|
return (
|
|
(True, None)
|
|
if to_role in _BOARD_ROLES or to_role == "main_pm"
|
|
else (False, f"Board cannot A2A {to_role}s. Route through main-pm.")
|
|
)
|
|
|
|
# Dispatch to role-specific handlers
|
|
handlers: dict[str, tuple[bool, str | None]] = {
|
|
"main_pm": _check_main_pm_a2a(to_role, to_team),
|
|
"cell_pm": _check_cell_pm_a2a(from_team, to_agent, to_role, to_team),
|
|
}
|
|
if from_role in handlers:
|
|
return handlers[from_role]
|
|
|
|
# Cell members - use helper
|
|
if from_team:
|
|
return _check_cell_member_a2a(from_agent, from_team, to_agent, to_role, to_team)
|
|
|
|
return False, f"A2A from {from_agent} to {to_agent} not permitted."
|
|
|
|
|
|
def get_a2a_route_hint(from_agent: str, to_agent: str) -> str:
|
|
"""Get a hint for how to properly route an A2A message."""
|
|
to_role = get_agent_role(to_agent)
|
|
from_team = get_agent_team(from_agent)
|
|
to_team = get_agent_team(to_agent)
|
|
|
|
# CEO is human - no A2A route, use notifications
|
|
if to_role == "ceo":
|
|
return "CEO is human. Use notify() for CEO communication."
|
|
|
|
# Cross-cell routing
|
|
if from_team and to_team and from_team != to_team:
|
|
from_pm = get_pm_for_team(from_team)
|
|
to_pm = get_pm_for_team(to_team)
|
|
return f"Route: {from_agent}→{from_pm}→main-pm→{to_pm}→{to_agent}"
|
|
|
|
# Cell member to management
|
|
if from_team:
|
|
cell_pm = get_pm_for_team(from_team)
|
|
return f"Route: {from_agent}→{cell_pm}→main-pm→board"
|
|
|
|
return "Use escalate_up() for proper escalation."
|