Consent System¶
How consent-gated operations work — auto-request in gateway, pre-flight bundling, session scope, risk levels
When to use¶
agent calls a capability that touches @requires_consent functions (handled transparently by the gateway)
Related capabilities¶
consent_list
Directions¶
Some work_buddy functions are protected by a @requires_consent decorator. The gateway handles consent transparently — when you call wb_run on a consent-gated capability, the gateway automatically requests consent from the user, waits for approval, and retries the operation. You do not need to manually orchestrate consent.
How it works¶
- Pre-flight check — capabilities declare
consent_operationslisting operations they may trigger. The gateway checks all upfront and bundles missing grants into ONE notification. This list enriches the notification body (UX) but is not required for correctness. - Consent context — when a consent-gated function executes, it establishes a thread-local context. Nested
@requires_consentcalls (e.g.,toggle_task→bridge.write_file) pass through automatically; the outer consent subsumes inner ones. No manual bookkeeping or*_rawfunction variants needed. - Fallback — if a
ConsentRequiredfires at runtime (unanticipated gate not covered by pre-flight or context), the gateway auto-requests and retries (max 2 retries). - You see: success (normal result), denied (
{status: "denied"}), or timeout ({status: "timeout", operation_id: "op_xxx"}). - On timeout — the request stays pending on all surfaces. Once the user approves, retry with
mcp__work-buddy__wb_run("retry", {"operation_id": "op_xxx"})to replay the original call without re-sending parameters. For Obsidian-bridge operations, useobsidian_retryinstead — it accepts the same{"operation_id": "op_xxx"}shape and adds bridge health-checks between attempts. The gateway's timeout return tells you which to use.
What not to do¶
The gateway handles consent for wb_run operations automatically. There is no agent-facing path that requires manual consent orchestration. Sidecar operations that need consent (e.g., agent_spawn) check via internal Python helpers (work_buddy/sidecar/dispatch/executor.py:_check_agent_spawn_consent), not through wb_run.
Do NOT use AskUserQuestion for consent. The notification system is the canonical consent surface — it reaches the user on their phone, in Obsidian, and on the dashboard. AskUserQuestion only works when the user is actively watching the terminal.
Agents cannot self-grant consent. The Python functions consent.grant_consent, consent.revoke_consent, and consent.resolve_consent_request are internal — they are called by the sidecar router (Obsidian out-of-band path), Telegram and dashboard handlers, and the gateway's own auto-consent flow. They are not exposed as agent-callable capabilities. The only way an agent gets consent is the user approving on a surface; the gateway handles the rest.
Interpreting Python-side grant/revoke return values. consent.grant_consent, consent.grant_consent_batch, and consent.revoke_consent are side-effect functions that return None — any call routed through wb_run records result_summary: null in the activity ledger. A null ledger entry is the expected return shape; it does NOT signal that the write failed. To verify a grant actually landed, call list_consents() (with agent_session_id for cross-process callers) — that is the canonical success check.
The cross-process subtlety: if a writer calls grant_consent without session_id, the grant lands in the cache's default session DB (typically the process's bootstrap session) and is invisible to a reader checking under a different agent_session_id. Same-process writer/reader pairs are safe because both resolve to the same default; cross-process writers (e.g. an MCP gateway dispatch on behalf of an agent in a different session) MUST pass the explicit session_id. Tests at tests/unit/test_grant_consent_session_routing.py characterise both shapes.
Grant scope and lifetime¶
Grants are stored session-scoped in a SQLite database at data/agents/<session>/consent.db. New sessions start with a clean slate — no grants carry over between user sessions. "Always" means "always within this session" (max 24h TTL).
When the sidecar's retry sweep replays a previously-consented operation, the consent check ALSO consults the originating user-session's grants (looked up by reference to the op record's originating_session_id). This means a consented operation that hits PWU and gets queued for retry will not fail with ConsentRequired on replay. Revocation in the originating session immediately disables future replays.
Workflow grants do NOT time-travel through the retry queue. The originating-session fallback considers individual op-grants only — workflow_class:* / workflow_run:* / legacy __workflow_consent__ keys are deliberately skipped on the replay path. The rationale: a workflow grant active when an op was queued may have been revoked, or have a class TTL that long expired by the time the sweep replays the op days later; the user's temporally-bounded trust in the workflow does not generalize to a later replay. Replays succeed only on individual op grants the user explicitly authorized for that op (or that exist in the current replay-time session).
Cross-session routing on out-of-band approval. When a user approves a consent prompt on the Obsidian modal after the gateway's in-window poll has already returned {status: "timeout"}, the plugin posts a consent_grant message to the messaging service. The sidecar's MessagePoller picks it up and routes the grant via resolve_consent_request, which looks up the notification's callback_session_id and writes grants to that session's DB — not the sidecar's. The originating agent's subsequent obsidian_retry (or fresh capability call) then sees the grant and proceeds. Without this routing, modal-approved grants would land in the sidecar's bootstrap-session DB where no agent could see them.
Bundle unbundling. When the gateway requests consent for a multi-op capability, the notification's operation field is a label of the form bundle:<capability_name> and the underlying ops live in consent_meta.context.operations. resolve_consent_request writes grants for each underlying op individually in addition to the bundle label, because the @requires_consent decorators check the individual operation names (e.g. tasks.create_task, obsidian.write_file), not the bundle. The bundle label survives as audit metadata.
Composable workflow consent¶
Starting a workflow may prompt the user once to authorize the workflow's component operations. Two grant levels live in the session's consent.db:
workflow_class:<name>— the "Allow for 15 min" or "Allow always" key. Set by the gateway's pre-flight prompt when the user approves a non-oncemode. TTL-bounded (15 min fortemporary, 24h foralways). While live, future invocations of the same workflow skip the pre-flight prompt.workflow_run:<name>:<run_id>— minted bystart_workflowfor every active run. Authorizes the workflow's sub-operations as constituents of that run. No TTL; revoked when the run completes (or via cascade when the class grant is explicitly revoked).
The gateway pre-flight prompt fires when ALL of the following hold:
- The workflow's
workflow_class:<name>grant is NOT live in this session. - The dispatch is NOT inside a
user_initiated()context. - The workflow's declared
consent_operationsinclude at least one moderate- or high-risk op (low-only workflows auto-bypass under the hybrid migration policy — see below).
User choices in the prompt: Allow once (no class grant; only the run grant covers this invocation), Allow for 15 min (class grant minted with 15-min TTL), Allow always (this session, 24h) (class grant minted with 24-h TTL), or Deny (the workflow does not start; start_workflow is short-circuited and the operation completes with a consent denied error).
Decorator carry path¶
The @requires_consent check inside a workflow run consults, in order:
- Individual op grant for
operation(highest priority). - Any live
workflow_run:*key in this session. - Any live
workflow_class:*key in this session. - Legacy
__workflow_consent__(deprecation-logged once per op per process).
Capabilities tagged with consent_weight="high" on their @requires_consent decorator (and mirrored on the Capability.consent_weight field) BYPASS the workflow-grant carry entirely — they always re-prompt individually, even inside an approved workflow run. This mirrors Cursor's destructive-command carve-out and OpenAI's isConsequential flag for GPT Actions. The default consent_weight mirrors the declared risk value, so the legacy behavior of "workflow blanket carries everything" is preserved for low-risk ops while high-risk ops are properly gated.
requires_individual_consent: true step flag¶
A workflow step can opt out of the run-grant carry via requires_individual_consent: true in the workflow definition. The conductor revokes the run grant before dispatching such a step and re-mints it after the step completes (or after the agent advances the workflow for a reasoning step). This lets a workflow author force a per-step prompt for one specific operation without affecting the rest of the DAG.
Low-weight workflow auto-bypass¶
Workflows whose constituent capabilities declare only low-weight consent_operations (or none at all) auto-bypass the pre-flight prompt. The gateway audit-logs each bypass as WORKFLOW_AUTO_BYPASS_LOW_WEIGHT | workflow=<name> so the scripts/audit_workflow_consent.py script can enumerate which workflows ride the bypass and which prompt. The bypass keeps read-only routines like task-search frictionless while still prompting for any workflow that touches moderate or high-risk operations.
Orphan reconciliation¶
A workflow's workflow_run:<name>:<run_id> key lives in the agent's consent.db while the run is active. An MCP-server restart wipes _ACTIVE_RUNS but leaves the on-disk grant; without cleanup, the orphaned key would silently authorize subsequent calls until something else revoked it. conductor.reconcile_workflow_consent(session_id) runs at session registration and revokes any workflow_run:* keys whose run_id is not present in _ACTIVE_RUNS for that session. Class grants are intentionally left alone — their TTL is the bound. The function also still handles the legacy __workflow_consent__ key for back-compat (preserves the historical return-shape contract: {"swept": True} / {"swept": False, "reason": "active_run_present"} / {"swept": False, "reason": "no_blanket"}).
Explicit cascade revoke¶
conductor.cascade_revoke_workflow(name, session_id=...) revokes the workflow_class:<name> key AND walks _ACTIVE_RUNS to revoke every in-flight run grant for that workflow. Used when the user explicitly withdraws trust mid-run — the ocap-CDT model where revoking the parent class grant propagates to all derived run grants. Without this, withdrawing trust would leave the in-flight runs riding their independent run grants until completion.
Legacy __workflow_consent__ (deprecated)¶
The legacy blanket key remains as a read-side fallback: _is_granted_in_session honors a live legacy blanket when no individual / workflow_run / workflow_class grant matches, and emits a single LEGACY_WORKFLOW_BLANKET_USED audit entry per operation per process so unconverted call sites stay greppable. The write-side uses only the new keys; the conductor and gateway no longer write __workflow_consent__. The read fallback is scheduled for removal once the audit log shows no LEGACY_WORKFLOW_BLANKET_USED hits in a release cycle.
Risk levels¶
Risk must be one of: "low", "moderate", "high" (validated by the Risk enum).
Call-stack-aware risk reduction (@reduces_risk_for)¶
A function decorated with @reduces_risk_for("some.op", "low") declares itself a safe invoker of some.op. While it is on the call stack, inner @requires_consent("some.op", ...) checks auto-pass (for "low") or prompt at the reduced risk (for "moderate"). Direct agent calls to the primitive — outside any safe-caller scope — still gate at the original risk.
This is the mechanism that lets read-only capabilities (e.g. daily_briefing) internally call obsidian.eval_js (registered at risk=high) without spamming prompts, while preserving high-risk gating for direct eval_js invocations from agents or the local-model tool preset. Declarations are module-level code (not config) and inspectable via list_risk_reducers() — adding or expanding one is a reviewed PR, not a runtime grant.
UI-click bypass (user_initiated)¶
The consent gate exists for autonomous agent operations — cron-fired scans, sidecar workflows, LLM-initiated actions where the user isn't watching. UI clicks are the inverse case: the user already consented by clicking the affordance. Pre-emptively re-prompting them for the action they explicitly initiated is bureaucratic UX and a known bug pattern.
work_buddy.consent.user_initiated(operation) is a context manager for that case. Wrap the critical section of a UI-driven endpoint (Flask handler reached via a button click, slash-command handler, CLI script the user invoked explicitly) and nested @requires_consent gates pass through, with audit-log entries (USER_INITIATED, USER_INITIATED_COVERED) distinguishing UI-driven actions from autonomous ones. The gateway's workflow pre-flight prompt is ALSO suppressed when dispatching inside a user_initiated() context.
from work_buddy.consent import user_initiated
@app.post("/api/user_jobs")
def api_user_job_create():
payload = request.get_json() or {}
with user_initiated("dashboard.user_job_create"):
result = create_user_job(payload)
return jsonify(result)
Canonical wiring: thread-approve clicks¶
The dashboard's thread-action endpoints (Approve, Confirm, Review-accept, Redirect, etc.) all funnel through work_buddy/dashboard/service.py:_post_thread_action, which wraps engine.transition in user_initiated() for any trigger in the _THREAD_USER_INITIATED_TRIGGERS set (execute, confirmed, review_accepted, provided, redirected, retry_cleanup, accept_cleanup_failure). The same wiring lives in work_buddy/threads/group.py:_run_child_accept for the cluster-umbrella Approve-All cascade — each child's accept-equivalent trigger runs inside user_initiated("thread.cascade_approve.<trigger>").
This is the right shape because thread actions are inferred by the LLM, surfaced on the confirmation card (with risk metadata, rationale, parameters all visible to the user), and only fire when the user clicks Approve. That click IS the consent boundary; the side-effect handler that dispatches the capability runs synchronously inside the transition, so the user_initiated context covers the entire downstream chain (action dispatch → @requires_consent decorator → underlying capability).
If you add a new dashboard endpoint that fires a state-entry side effect invoking a @requires_consent capability, follow this pattern. If you bypass _post_thread_action and call engine.transition directly from a Flask handler, you must add the wrapper yourself or the user will see a ConsentRequired re-prompt after they've already clicked Approve.
Use sparingly elsewhere. Outside the thread-approve path, the right callers are: dashboard POST handlers reachable only via a button click; CLI scripts the user invoked explicitly; slash-command handlers. Do NOT use this in code an agent can reach without a user click — that defeats the consent model. Reentrant; thread-local; restores depth on exception.