Skip to content

Agent Governance — Developer Reference

There is no standalone “Action Gateway” microservice. Agent governance is implemented as three plain Strapi content-types in sencai.space/src/api/: agent-policy, approval-request and elevation-request. Enforcement is deny-by-default and lives in Strapi controllers/policies — not in a separate service, and not in OPA/Rego.

agent-policy.autonomy_level is an enumeration L0L3 with these exact semantics:

LevelNameBehaviour
L0Observe onlyAgent only reports and queries. No writes.
L1Suggest onlyAgent proposes actions but nothing executes — purely advisory, no writes even if a human “approves” the suggestion. Do not conflate with L2.
L2Execute with approvalA human approves a pending request, then the agent executes.
L3Auto-execute within policyPre-approved bounds — no human step, action runs automatically as long as it stays within the policy (max_blast_radius, allowed_action_types, freeze windows).

Default is L0. A policy with no matching active row for the requested org/environment/action_type is a block (deny-by-default).

Scoped by scope_type (org / environment / action_type) + scope_value, per organisation. Key fields:

  • autonomy_levelL0L3 (see ladder above)
  • freeze_windows (JSON) — time-based blocks, e.g. deploy freezes. Format: [{ name, start: "HH:MM", end: "HH:MM", days: number[], timezone }] (days: 0=Sunday…6=Saturday). All agent actions are blocked while any window matches, regardless of autonomy level.
  • max_blast_radius — enum single_resource / service_tier / availability_zone / region; caps the maximum scope of impact a single action under this policy may have
  • allowed_action_types / blocked_action_types (JSON arrays) — blocked_action_types always takes precedence
  • is_active (boolean)
  • created_by_agent (boolean) — guards against agent-initiated self-mutation of its own policy

The schema also carries a set of fields explicitly documented in the schema itself as “Legacy: OPA-compatible”policy_document (JSON), scope (global/per-provider/per-env/per-action), provider, environment, action_pattern. These exist for backward-compatible data shape only. There is no live OPA evaluation anywhere in the codebase: no .rego policy files and no opa eval CLI invocation exist in this monorepo. Do not write policy-authoring docs that assume a Rego bundle pipeline — enforcement is plain TypeScript in the Strapi controllers (agent-policy controller + the approval/elevation controllers below).

Custom routes on agent-policy:

POST /api/agent-policies/:documentId/activate
POST /api/agent-policies/:documentId/deactivate

Both are org-scoped (caller must be a member of the policy’s organisation, or a platform admin) and emit gateway.* audit events.

Stored in the approval-request content-type (sencai.space/src/api/approval-request/), not cloud-approval-request.

  • action_type (string) — machine-readable action identifier, e.g. backup.policy.apply, runbook.execute, bulk.deauth
  • payload (JSON) — the action payload to execute on approval
  • status — enum pending / approved / rejected / expired
  • blast_radius (JSON) — { resource_count, resource_types, estimated_impact }, populated by dry-run
  • cost_delta_usd (decimal) — estimated cost change in USD (negative = savings)
  • dry_run_status — enum pending / running / completed / failed
  • dry_run_result (JSON) — result of the pre-execution simulation
  • rollback_plan (text)
  • expires_at (datetime) — set to created_at + 4 hours at create time

The 4-hour TTL is a fixed constant (TTL_MS = 4 * 60 * 60 * 1000) in the controller. No per-organisation override field exists on the organisation content-type or anywhere else in the codebase — the TTL is currently not configurable.

All routes require global::hybrid-auth. The mutation sub-routes are declared before the generic /:id routes to avoid Strapi resolving them as findOne/update:

GET /api/approval-requests
GET /api/approval-requests/:id
POST /api/approval-requests
POST /api/approval-requests/:id/approve
POST /api/approval-requests/:id/reject
POST /api/approval-requests/:id/dry-run
PUT /api/approval-requests/:id
DELETE /api/approval-requests/:id
  • approve — only valid from pending; if expires_at has passed, the record is flipped to expired and the call returns 400 instead of approving. On success sets approved_at, approved_by, emits approval.approved audit event.
  • reject — only valid from pending; requires a non-empty rejection_reason. Emits approval.rejected.
  • dry-run — simulates blast radius and cost delta without executing anything real. Counts affected cloud-instance/cloud-network records for the request’s organisation, flags destructive action types (destroy/terminate/delete/bulk.deauth pattern), and writes dry_run_status, blast_radius, dry_run_result, cost_delta_usd. Emits approval.dry_run.completed.
  • update (PUT /:id) — allowlisted metadata edit only (action_label, rollback_plan). It can never change status, organisation, requester, approved_by, expires_at, or cost/blast-radius fields — those are exclusively owned by the approve/reject/dry-run actions.
  • find/findOne/delete are org-scoped: a caller only sees/deletes requests belonging to organisations they are a member of (platform admins see everything). findOne on a request outside the caller’s orgs returns 404, not 403, to avoid leaking existence across tenants.
Agent / operator
→ POST /api/approval-requests (action_type, payload, blast_radius?, cost_delta?, ...)
status = "pending", expires_at = now + 4h
→ optionally: POST /api/approval-requests/:id/dry-run
simulates blast radius + cost delta, no execution
→ Approver reviews in the platform UI
→ POST /api/approval-requests/:id/approve
(rejected instead → POST .../reject with rejection_reason)
→ status = "approved"
→ caller executes the actual action out-of-band (cloud-connector / fleet agent / runbook engine)
→ each transition emits an approval.* audit event via @sencai/audit

There is no dedicated real-time push channel for approval requests (no WebSocket endpoint exists for this). Approval state changes are only observable by polling the REST endpoints above or via the audit event they emit; a broader real-time notification would go through the platform’s general notification.events/platform.events event architecture (see notification-service and event-store-consumer), but no approval-specific wiring into that path currently exists — confirm with the service owner before documenting a live notification flow here.

Cross-tenant capability checks (MSP/agency access to another organisation’s resources) are enforced by the Strapi policy global::is-organisation-scoped-or-cross-tenant (src/policies/is-organisation-scoped-or-cross-tenant.ts, WAVE-19 / F2.GATEWAY.06):

  • Reads the active tenant context (TenantContext, populated from organisation-relationship.scope) via getTenantContext().
  • If the request is not cross-tenant (isCrossTenant === false), the policy is a no-op and passes through — direct org members are governed by ordinary org-scoping policies instead.
  • If the request is cross-tenant, it checks whether the relationship’s granted capabilities (e.g. *:read, *:*, compute:read, network:read, cost:read, iam:read) cover the capability required by the route’s config.capability.
  • Missing config.capability on the route fails closed (deny).
  • On denial: returns 403 and publishes a CROSS_TENANT_ACCESS_DENIED audit event with the required/available capabilities and the relationship id.

Applied on routes that expose cross-tenant compute/network/iam/cost data, alongside route-specific policies:

policies: [
'global::hybrid-auth',
{ name: 'global::is-organisation-scoped-or-cross-tenant', config: { capability: 'compute:read' } },
]

Note from the policy’s own documentation: this policy alone does not provide tenant isolation for non-cross-tenant calls — routes that rely on it as their only policy need global::is-organisation-scoped (or equivalent controller-level org scoping) alongside it.

Just-in-time, cross-tenant privilege elevation lives in the elevation-request content-type.

  • requesting_user, requesting_org, target_org — the user and the two organisations involved in the cross-tenant grant
  • requested_capabilities (JSON array) — capability strings being requested
  • justification (text, required)
  • duration_minutes (integer, 1–480, i.e. max 8 hours) — not a TTL-in-seconds field
  • status — enum pending / approved / denied / expired / revoked
  • approver, approved_at, auto_expire_at (= approved_at + duration_minutes)
  • denial_reason
  • used (boolean) / used_at (datetime) — single-use guard: the grant is consumed on first use, not merely time-bound. Once used = true, the elevation check treats the grant as inactive even if auto_expire_at hasn’t passed yet.
  • relationship — link to the organisation-relationship record backing the cross-tenant grant
  • home_region (eu/us/apac) and cell_id — CELL data-residency fields (F2.CELL.01)
GET /api/elevation-requests
GET /api/elevation-requests/:id
POST /api/elevation-requests
PUT /api/elevation-requests/:id/approve
PUT /api/elevation-requests/:id/deny
POST /api/elevation-requests/:id/consume
  • create — caller must be a member of requesting_org (or platform admin). Validates requested_capabilities as a non-empty array and duration_minutes as an integer in 1..480. Sets status = "pending", used = false. Emits ELEVATION_REQUESTED.
  • approve (PUT :id/approve) — gated by global::hybrid-auth + global::is-elevation-approver (owner/admin of target_org — not a platform-wide admin check). Sets status = "approved", approved_at = now, auto_expire_at = approved_at + duration_minutes, approver = caller. Emits ELEVATION_APPROVED (risk_level critical).
  • deny (PUT :id/deny) — same approver gating. Sets status = "denied", denial_reason, approver, approved_at. Emits ELEVATION_DENIED.
  • consume (POST :id/consume) — only the original requesting_user may consume; authorization is enforced in the controller itself (not a route policy). Delegates to the service’s consumeElevation(), which flips used = true/used_at as a single-use guard. Emits ELEVATION_USED.
  • find/findOne — non-admin callers only see requests where they are the requester, or a member of requesting_org/target_org.

Note: there is no plain PUT /api/elevation-requests/:id generic-update path used for approval — approval and denial are dedicated actions (:id/approve, :id/deny) gated by global::is-elevation-approver, distinct from the generic core update handler.

Requesting user (member of requesting_org)
→ POST /api/elevation-requests
{ requesting_org, target_org, requested_capabilities, justification, duration_minutes }
→ status = "pending" (ELEVATION_REQUESTED audit event)
→ owner/admin of target_org:
PUT /api/elevation-requests/:id/approve → status = "approved", auto_expire_at set
PUT /api/elevation-requests/:id/deny → status = "denied"
→ requesting_user calls POST /api/elevation-requests/:id/consume
to actually exercise the grant — single-use: used = true after first consume
→ grant is inactive once used=true OR auto_expire_at has passed, whichever comes first

Check expires_at — after 4 hours from creation the request auto-expires on the next approve call attempt (or via the enforcement check inline), not via a separate polling cron. Expired requests must be re-submitted; there is no re-open path.

Cross-tenant capability denied unexpectedly (403, CROSS_TENANT_ACCESS_DENIED)

Section titled “Cross-tenant capability denied unexpectedly (403, CROSS_TENANT_ACCESS_DENIED)”

Check the organisation-relationship.scope for the relationship between the caller’s org and the target org — the granted capability strings (*:*, *:read, <resource>:<action>) must cover the capability configured on the route. There is no OPA bundle or .rego file to inspect; the matching logic is the plain hasCapability() function in src/policies/is-organisation-scoped-or-cross-tenant.ts.

Verify the relevant agent-policy row is is_active = true, autonomy_level = "L3", the action type isn’t in blocked_action_types, and no freeze_windows entry currently matches (time/day/timezone).

Elevation grant approved but action still rejected

Section titled “Elevation grant approved but action still rejected”

Confirm the grant hasn’t already been consumed (used = true) — grants are single-use regardless of how much of duration_minutes remains. Also check auto_expire_at hasn’t passed.