Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8bf25d0
feat: harden Discord actor-scoped workflows
michaelmwu Sep 1, 2026
707e774
fix: make notification claims replay safe
michaelmwu Sep 1, 2026
e3cf10c
Enforce Discord actor capability scopes
michaelmwu Sep 1, 2026
6330eb0
Allow narrowed GitHub credential routes
michaelmwu Sep 1, 2026
57e4606
fix: close Discord workflow authorization gaps
michaelmwu Sep 1, 2026
a6208ff
fix: serialize actor policy and retry ingress claims
michaelmwu Sep 1, 2026
a0ae20a
fix: close Discord continuation boundaries
michaelmwu Sep 1, 2026
4e713a7
fix: preserve reviewed bot identities through dispatch
michaelmwu Sep 1, 2026
9f483a0
fix: persist reviewed Discord action boundaries
michaelmwu Sep 1, 2026
ccb5aa6
fix(discordbot): ignore replies addressed to other users (#28)
michaelmwu Sep 1, 2026
a67a816
fix: bind Discord bot identities to policies
michaelmwu Sep 1, 2026
8b13787
fix: enforce Discord GitHub App repository scope
michaelmwu Sep 1, 2026
24353d0
fix: enforce Discord GitHub credential boundaries
michaelmwu Sep 1, 2026
2c4071f
fix: preserve Discord actor credential policy
michaelmwu Sep 1, 2026
8cab2fe
fix: reject noncanonical Discord GitHub secrets
michaelmwu Sep 1, 2026
420c167
fix: validate Discord GitHub rule replacements
michaelmwu Sep 1, 2026
51fedc4
fix: reject CIDR rules in Discord policy roles
michaelmwu Sep 1, 2026
c55a7fb
fix: enforce Discord GitHub policy for all credentials
michaelmwu Sep 1, 2026
5fe7b3e
Keep Discord delivery claims provisional until dispatch
michaelmwu Sep 8, 2026
ff5156c
Recover failed Discord claim finalization
michaelmwu Sep 8, 2026
040dee1
Finalize Discord admission after durable handoff
michaelmwu Sep 8, 2026
3df0eb3
Filter hoisted credentials through Discord policy
michaelmwu Sep 8, 2026
6528a45
Bind Discord capabilities to reviewed roles
michaelmwu Sep 8, 2026
b9c5626
Keep Discord policy and execution claims durable
michaelmwu Sep 8, 2026
8dee05a
Enforce reviewed Discord capabilities on every update
michaelmwu Sep 8, 2026
1036338
Allow metadata updates for unassigned Discord actors
michaelmwu Sep 8, 2026
b170249
Normalize GitHub hosts at the policy boundary
michaelmwu Sep 8, 2026
a07e419
Stabilize provisional Discord admission test
michaelmwu Sep 8, 2026
c036e1b
Reconcile Discord actors after role changes
michaelmwu Sep 8, 2026
523e1a2
Keep Discord actor policy revocation atomic
michaelmwu Sep 8, 2026
4a5d0a5
Lock Discord policy through actor assignment
michaelmwu Sep 8, 2026
73b1adb
Reserve workflow approval idempotency keys
michaelmwu Sep 8, 2026
4266584
Apply Rust workflow formatting
michaelmwu Sep 8, 2026
d8d3903
Bump Centaur chart after parallel merges
michaelmwu Sep 8, 2026
413bbd1
Block GitHub broker token reuse in Discord roles
michaelmwu Sep 8, 2026
0ca08b8
Clarify OAuth credential test fixtures
michaelmwu Sep 8, 2026
d542176
Avoid persisting credential objects in policy tests
michaelmwu Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contrib/chart/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: v2
name: centaur
description: Helm chart for the trusted Centaur control plane
type: application
version: 0.1.136
version: 0.1.137
appVersion: "0.1.0"
dependencies:
- name: connect
Expand Down
14 changes: 14 additions & 0 deletions contrib/chart/templates/apirs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
{{- $console := include "centaur.consoleValues" . | fromYaml -}}
{{- $mcpPublicUrl := default .Values.slackbotv2.mcpPublicUrl .Values.apiRs.mcpPublicUrl -}}
{{- $apiRsName := include "centaur.componentName" (dict "root" . "component" "api-rs") -}}
{{- $discordApprovalRoles := list -}}
{{- range $binding := .Values.discordbot.roleBindings -}}
{{- if (get $binding "can_approve") -}}
{{- $discordApprovalRoles = append $discordApprovalRoles (get $binding "principal_role") -}}
{{- end -}}
{{- end -}}
{{- $repoCacheStorageType := include "centaur.repoCacheStorageType" . -}}
{{- $repoCacheUsePvc := eq $repoCacheStorageType "persistentVolumeClaim" -}}
{{- $repoCachePvcName := include "centaur.repoCachePvcName" . -}}
Expand Down Expand Up @@ -239,6 +245,14 @@ spec:
key: {{ printf "%s%s" $.Values.secretManager.envPrefix $ingress.key }}
{{- end }}
{{- end }}
{{- if .Values.discordbot.enabled }}
- name: DISCORDBOT_INTERNAL_URL
value: {{ printf "http://%s:%v" (include "centaur.componentName" (dict "root" . "component" "discordbot")) 3001 | quote }}
- name: DISCORDBOT_APPROVAL_ROLE_ALLOWLIST
value: {{ join "," ($discordApprovalRoles | uniq) | quote }}
{{- end }}
- name: CENTAUR_ACTION_PROPOSAL_BINDINGS_JSON
value: {{ .Values.apiRs.actionProposalBindings | toJson | quote }}
- name: BIND_ADDR
value: {{ printf "0.0.0.0:%v" .Values.apiRs.port | quote }}
- name: SLACK_BOT_TOKEN
Expand Down
24 changes: 20 additions & 4 deletions contrib/chart/templates/discordbot.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
{{- if .Values.discordbot.enabled }}
{{- $apiRsName := include "centaur.componentName" (dict "root" . "component" "api-rs") -}}
{{- $guildAllowlist := required "discordbot.guildAllowlist is required when discordbot is enabled" .Values.discordbot.guildAllowlist -}}
{{- $channelAllowlist := required "discordbot.channelAllowlist is required when discordbot is enabled" .Values.discordbot.channelAllowlist -}}
{{- $roleBindings := required "discordbot.roleBindings must contain reviewed role policy when discordbot is enabled" .Values.discordbot.roleBindings -}}
{{- if eq (len $roleBindings) 0 -}}
{{- fail "discordbot.roleBindings must contain reviewed role policy when discordbot is enabled" -}}
{{- end -}}
apiVersion: apps/v1
kind: Deployment
metadata:
Expand Down Expand Up @@ -67,11 +73,21 @@ spec:
- name: DISCORDBOT_USER_NAME
value: {{ .Values.discordbot.userName | quote }}
- name: DISCORDBOT_GUILD_ALLOWLIST
value: {{ .Values.discordbot.guildAllowlist | quote }}
value: {{ $guildAllowlist | quote }}
- name: DISCORDBOT_CHANNEL_ALLOWLIST
value: {{ .Values.discordbot.channelAllowlist | quote }}
- name: DISCORDBOT_TRIGGER_ROLE_ALLOWLIST
value: {{ .Values.discordbot.triggerRoleAllowlist | quote }}
value: {{ $channelAllowlist | quote }}
- name: DISCORDBOT_ROLE_BINDINGS_JSON
value: {{ $roleBindings | toJson | quote }}
{{- if gt (len .Values.discordbot.triggerBotBindings) 0 }}
- name: DISCORDBOT_TRIGGER_BOT_BINDINGS_JSON
value: {{ .Values.discordbot.triggerBotBindings | toJson | quote }}
{{- end }}
- name: DISCORDBOT_CONTINUATION_TTL_MS
value: {{ .Values.discordbot.continuationTtlMs | quote }}
- name: DISCORDBOT_INGRESS_MAX_EVENT_AGE_MS
value: {{ .Values.discordbot.ingressMaxEventAgeMs | quote }}
- name: DISCORDBOT_INGRESS_DELIVERY_TTL_MS
value: {{ .Values.discordbot.ingressDeliveryTtlMs | quote }}
{{- if .Values.discordbot.mentionRoleIds }}
- name: DISCORD_MENTION_ROLE_IDS
value: {{ .Values.discordbot.mentionRoleIds | quote }}
Expand Down
22 changes: 21 additions & 1 deletion contrib/chart/templates/networkpolicy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,16 @@ spec:
ports:
- protocol: TCP
port: {{ $console.service.httpPort }}
{{- if .Values.discordbot.enabled }}
# Authenticated workflow delivery to Discordbot's internal endpoint.
- to:
- podSelector:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "discordbot") | nindent 14 }}
ports:
- protocol: TCP
port: 3001
{{- end }}
- ports:
- protocol: TCP
port: 443
Expand Down Expand Up @@ -514,7 +524,8 @@ spec:
- Ingress
- Egress
ingress:
# Only health probes reach discordbot; nothing routes traffic to it (the Gateway is outbound).
# Health probes and the API workflow runtime's narrow authenticated delivery
# endpoint are the only inbound paths. Discord event ingress remains Gateway-only.
- from:
{{- range $ingressSourceNamespaces }}
- namespaceSelector:
Expand All @@ -524,6 +535,15 @@ spec:
ports:
- protocol: TCP
port: 3001
{{- if .Values.apiRs.enabled }}
- from:
- podSelector:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "api-rs") | nindent 14 }}
ports:
- protocol: TCP
port: 3001
Comment thread
michaelmwu marked this conversation as resolved.
{{- end }}
egress:
{{- if .Values.apiRs.enabled }}
- to:
Expand Down
23 changes: 23 additions & 0 deletions contrib/chart/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,29 @@
"sandboxHotIdleGraceSecs": { "type": "integer", "minimum": 0 },
"workflowHostSandbox": { "type": "boolean" },
"workflowHostResources": { "type": "object" },
"actionProposalBindings": {
"type": "array",
"maxItems": 128,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["observer_workflow", "action_type", "action_workflow"],
"properties": {
"observer_workflow": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$"
},
"action_type": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}$"
},
"action_workflow": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$"
}
}
}
},
"etl": {
"type": "object",
"properties": {
Expand Down
34 changes: 30 additions & 4 deletions contrib/chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,13 @@ apiRs:
workflowHostSandbox: true
workflowEnableMode: all
workflowAllowedNames: ""
# Reviewed observer/action bindings for durable action proposals. The runtime
# rejects persistence and approval unless the exact observer_workflow +
# action_type tuple selects the proposed action_workflow.
actionProposalBindings: []
# - observer_workflow: weekly_ops_review
# action_type: github:create_improvement_pr
# action_workflow: execute_approved_improvement
# Requests/limits for the workflow-host sandbox pod (k8s resources map).
workflowHostResources: {}
# Scheduled ETL workflow configuration. The chart renders these into api-rs
Expand Down Expand Up @@ -737,7 +744,7 @@ githubbot:

# Discord chat ingress — mirrors slackbotv2, forwards to the api-rs control plane (:8080) over a
# persistent Discord Gateway connection. Off by default; needs a Discord app + Message Content
# Intent + guild/channel/role allowlists. Always exactly one replica (singleton Gateway session).
# Intent + guild/channel/role policy. Always exactly one replica (singleton Gateway session).
discordbot:
enabled: false
image:
Expand All @@ -749,9 +756,28 @@ discordbot:
guildAllowlist: ""
# Comma/space-separated parent channel IDs. Required and fail-closed.
channelAllowlist: ""
# Comma/space-separated immutable role IDs allowed to trigger/continue human turns.
# Required and fail-closed. Role names are never used for authorization.
triggerRoleAllowlist: ""
# Reviewed immutable Discord-role bindings. Enabling the deployment with an
# empty list fails Helm rendering. Multiple matching roles use explicit
# priority; an equal-priority semantic conflict is denied. Use exact repo
# names only; wildcards and role names are rejected at process startup.
roleBindings: []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# - role_id: "100000000000000001"
# capability_class: github:observe
# principal_role: discord-observer
# can_approve: false
# priority: 0
# repository_scope: [example-org/example-repo]
# project_scope: []
# Root-to-continuation TTL and replay/audit retention, in milliseconds.
continuationTtlMs: 86400000
ingressMaxEventAgeMs: 300000
ingressDeliveryTtlMs: 604800000
# Exact non-human identities bound to an existing, non-approver role policy.
# A bare bot/webhook allowlist is intentionally insufficient: the identity
# must select one reviewed bundle and cannot approve a proposal.
triggerBotBindings: []
# - identity_id: "100000000000000001" # bot user, application, or webhook ID
# role_id: "100000000000000002" # one entry in roleBindings
# Comma/space-separated role IDs whose mentions also trigger the bot.
mentionRoleIds: ""
# Rename auto-created threads to the triggering message; set false to keep generic names.
Expand Down
63 changes: 63 additions & 0 deletions docs/pages/extend/workflows-v2.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ Supported v2 primitives:
| `ctx.run_model_ensemble(...)` | Supported for replay-safe cross-model review, bounded fallback, and validated synthesis |
| `ctx.call_tool(...)` | Supported through the generated `centaur-tools call` bridge in the workflow-host sandbox |
| `ctx.post_to_slack(...)` | Supported |
| `ctx.post_to_discord(...)` | Supported for authenticated, allowlisted, idempotent workflow notifications |
| `ctx.put_action_proposal(...)` | Supported for validated, canonical, expiring action proposals |
| `ctx.transition_notification_state(...)` | Supported for durable semantic notification suppression/resolution |
| `ctx._pool` | Supported when the workflow-host sandbox receives `DATABASE_URL` |
| `WEBHOOKS` | Supported |
| `SCHEDULE` | Supported |
Expand Down Expand Up @@ -113,6 +116,66 @@ async def handler(inp: dict, ctx: WorkflowContext) -> dict:
for that message and require the app installation to grant
`chat:write.customize`. Omitting them preserves the app's default identity.

### Keep observation separate from approval and action

An observer workflow can persist a typed proposal without gaining mutation
authority. The runtime validates its exact action type, action workflow,
repository, refs, source IDs, bounded evidence, parameters, and validation
statuses, then derives the canonical `sha256:` fingerprint:

```python
proposal = await ctx.put_action_proposal(
{
"action_type": "github:create_improvement_pr",
"action_workflow": "execute_approved_improvement",
"repository": "example-org/automation",
"base_ref": "0123456789abcdef0123456789abcdef01234567",
"head_ref": None,
"source_ids": {"sentry_issue": "OPS-42"},
"evidence": [
{
"source_type": "sentry",
"source_id": "OPS-42",
"content_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
}
],
"validations": [{"name": "source_current", "status": "passed"}],
"parameters": {"summary": "Bounded operator-facing summary"},
},
expires_in_seconds=7 * 24 * 60 * 60,
)
```

Only authenticated Discord ingress with a reviewed approval role can consume
that exact fingerprint. Consumption and action-run creation are idempotent;
repeated approval returns the existing action. Expired, changed, malformed, or
out-of-scope proposals fail closed and require a new observation. The action
workflow receives the normalized proposal plus immutable approval context and
must enforce the tuple again before any write.

For quiet recurring workflows, derive semantic state from stable proposal,
source, action, and blocker identifiers—not model prose, timestamps, run IDs,
or raw errors:

```python
transition = await ctx.transition_notification_state(
"weekly_ops_review:automations",
semantic_fingerprint,
"proposal_pending",
)
if transition["notify"]:
await ctx.post_to_discord(
"1542739830591459369",
concise_digest,
delivery_id=f"weekly-ops:{semantic_fingerprint}",
)
```

The state is durable. A storage failure returns `state_persisted: false` and
permits one concise handoff, but never authorizes a mutation. The Discord
delivery endpoint rejects non-allowlisted channels, disables mentions and
embeds, and deduplicates a stable delivery ID.

### Make agent turns explicit

Use `ctx.agent_turn(...)` when the workflow needs an agent sandbox:
Expand Down
Loading