Skip to content

feat(hitl): interactive mode + scoped_mcp_hitl_confirm tool - #61

Merged
TadMSTR merged 3 commits into
mainfrom
feature/hitl-interactive-mode
Jul 21, 2026
Merged

feat(hitl): interactive mode + scoped_mcp_hitl_confirm tool#61
TadMSTR merged 3 commits into
mainfrom
feature/hitl-interactive-mode

Conversation

@TadMSTR

@TadMSTR TadMSTR commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Summary

Adds hitl.mode: enforce | interactive to scoped-mcp manifests. Default is enforceno behavior change for any manifest that doesn't opt in.

interactive mode fires the same Matrix notify and the same immediate reject (the agent still stops and asks), but resolves the pending request in-session via a new companion tool scoped_mcp_hitl_confirm(approval_id, decision) that the agent calls directly — replacing the token-expensive "read the Matrix message and self-resolve via a system-ops shell call" workaround. It reuses the shared hitl_endpoint.approve/deny logic (no duplicated approval path).

Build: scoped-mcp-hitl-bypass-mode-2026-07. Does not touch Vikunja #203 (the broken enforce-mode bot→endpoint path — deferred, out of scope).

Changes

  • manifest.py — new HitlConfig.mode enum (enforce/interactive, default enforce).
  • registry.py_register_hitl_confirm_tool; registered only when hitl.mode == interactive AND the agent actually gates tools AND a state backend exists. An enforce-mode / unattended agent never gets the tool, so it can't self-resolve a headless run's gated calls.
  • hitl_endpoint.py / hitl_http.py / registry_db.py — thread a resolved_via audit tag (matrix_bot | courier | interactive_self_service) so the in-session shortcut is always distinguishable from the real out-of-band paths. Written only when supplied, so a later consume/expire transition never clobbers the approve-time channel.
  • migrations/0002_hitl_resolved_via.sqlADD COLUMN IF NOT EXISTS resolved_via (fail-open until applied; safe to run before the code).
  • docs/manifest-schema.md — documents mode + the interactive trust tradeoff.

Trust tradeoff (interactive only, by design)

scoped_mcp_hitl_confirm trusts the agent's own report that the operator approved in the current conversation turn — the same trust level every other interactive tool call already runs under. It is registered only for interactive-mode agents, so enforce-mode (unattended) agents can never reach it. The agent-facing contract ("only after an explicit in-conversation approve/deny") lives in the tool docstring, docs/manifest-schema.md, and the developer/sysadmin CLAUDE.md.

Tests

  • test_hitl_interactive.py (new) — registration gating (present for interactive, absent for enforce/default/no-approval), approve/deny behavior via the FastMCP client, invalid decision, cross-agent isolation, resolved_via tagging.
  • test_manifest.pymode default/accept/reject.
  • test_registry_db.pyresolved_via SQL param positioning (guarded + unguarded), and that it's omitted when not supplied.

Full suite: 737 passed, 11 skipped (Dragonfly/Postgres/otel-gated). ruff check + ruff format --check clean.

Deploy note

Venv upgrade to this release must land before the companion manifest flip (host-forge-scripts) — HitlConfig(extra="forbid") rejects the mode field on older builds. Apply 0002_hitl_resolved_via.sql to agent-postgres as part of deploy.

🤖 Generated with Claude Code

developer-agent added 3 commits July 21, 2026 12:56
Add hitl.mode: enforce|interactive (default enforce — no behavior change for
any manifest that doesn't opt in).

- manifest.py: new HitlConfig.mode enum field, default enforce.
- registry.py: register scoped_mcp_hitl_confirm ONLY for interactive-mode agents
  that actually gate tools. An enforce-mode / unattended agent never gets the
  tool registered, so it can't self-resolve a headless run's gated calls.
- hitl_endpoint.py / hitl_http.py / registry_db.py: thread a resolved_via audit
  tag (matrix_bot | courier | interactive_self_service) so the in-session
  shortcut is always distinguishable from the real out-of-band paths. The
  interactive tool reuses hitl_endpoint.approve/deny unchanged — no duplicated
  approval logic. resolved_via is written only when supplied, so a later
  consume/expire transition never clobbers the approve-time channel.
- migrations/0002_hitl_resolved_via.sql: ADD COLUMN IF NOT EXISTS resolved_via
  (fail-open until applied; safe to run before the code).
- docs/manifest-schema.md: document mode + the interactive trust tradeoff.

Trust tradeoff (interactive only, by design): scoped_mcp_hitl_confirm trusts the
agent's own report that Ted approved in the current turn — the same trust level
every other interactive tool call already runs under. Agent-facing contract
(only call after explicit in-conversation approve/deny) lives in the tool
docstring, manifest-schema.md, and the developer/sysadmin CLAUDE.md.

Does not touch Vikunja #203 (broken enforce-mode bot->endpoint path, deferred).

Build: scoped-mcp-hitl-bypass-mode-2026-07

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

agent-id: developer
Audit INFO (scoped-mcp-hitl-bypass-mode-2026-07): assert that a state-backend
error inside the shared approve logic makes scoped_mcp_hitl_confirm return
backend_unavailable and write no pre-approval token — never fabricate an
approval. Closes the one coverage gap the audit noted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

agent-id: developer
Stamp CHANGELOG 1.11.0 and bump version. Records the audit outcome
(1 HIGH accepted + tracked, 2 Info) for scoped-mcp-hitl-bypass-mode-2026-07.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

agent-id: developer
@TadMSTR
TadMSTR merged commit 26637bf into main Jul 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant