Skip to content

Opt-in TAP mode — reach any service with no keys in the agent process - #34

Open
nanaknihal wants to merge 12 commits into
CopilotKit:mainfrom
nanaknihal:tap-mode
Open

Opt-in TAP mode — reach any service with no keys in the agent process#34
nanaknihal wants to merge 12 commits into
CopilotKit:mainfrom
nanaknihal:tap-mode

Conversation

@nanaknihal

Copy link
Copy Markdown

Opt-in TAP mode — reach any service with no keys in the agent process

What this adds

An optional, off-by-default way to connect OpenTag's sources without
holding service API keys in the agent process. Set one env var
(TAP_AGENT_KEY) and the agent reaches GitHub, Linear, Notion, PostHog —
and any other service the team connects — through the
TAP
credential proxy: the secret is injected server-side, pinned to that
service's own API host, and every call is audited.

Without TAP_AGENT_KEY, nothing changes. No new dependencies (stdlib
urllib only). It composes per service with the existing integrations: any
key still set in .env keeps its direct MCP connection; each key you leave
out moves that service behind TAP. No all-at-once migration.

# .env — instead of LINEAR_API_KEY / NOTION_MCP_AUTH_TOKEN / POSTHOG_PERSONAL_API_KEY / …
TAP_AGENT_KEY=tap_...

Why

OpenTag's confirm_write card is good conversation UX, but the service
keys still live in the agent process — one prompt injection away from
env | grep -i key. TAP mode removes the thing that can leak, and makes
the write gate enforced rather than advisory where it matters:

  • Any service, zero code. Two generic tools (tap_discover +
    tap_call) cover every service without a direct MCP connection here.
    Connect a new service in the TAP dashboard and the bot can use it on the
    next Slack message.
  • No keys in the process. For TAP-covered services, a prompt-injected
    agent has no key to leak and nowhere else to send one — the credential is
    injected server-side and destination-pinned to the service's own host.
  • One human approval per write — never two. tap_call reads the
    credential's declared TAP policy: when TAP will hold the call for an
    approver, that enforced server-side approval is the single gate (the
    in-channel card is skipped); when TAP auto-approves the route, the usual
    confirm_write card appears. Any doubt falls back to showing the card.
  • Lazy setup from inside Slack. Ask for a service that isn't connected
    and the bot replies with an origin-verified setup link — the secret goes
    into the TAP dashboard, never into chat.

What it looks like

Boot, with no service keys anywhere in the process:

[TOOLS] TAP mode: no direct service keys set — all services are reached via tap_call, no keys in process
[TOOLS] TAP connectivity OK — credentials available: github, linear, notion, posthog
[AGENT] Main tools: ['tap_discover', 'tap_call', 'tap_check_approval']

Security notes (reviewed adversarially before submission)

  • The agent never dials upstream targets itself — only the proxy.
    Destination pinning and SSRF guards are server-side.
  • Human-facing TAP links (credential setup, approvals) are
    origin-checked before the model may relay them; anything that isn't
    TAP's origin is stripped. This closes the "injected model swaps the setup
    link for a phishing page" seam.
  • Model-supplied x-tap-* headers are dropped (spoof-tested); header names
    are validated against the RFC 7230 token charset.
  • The read/write split fails toward confirmation: ambiguous calls and
    body-less GraphQL POSTs are treated as writes.
  • TAP_PROXY_URL requires https except toward loopback (it receives the
    agent key).
  • Parallel tool calls are disabled: an interrupt freezing a multi-call step
    corrupts the Responses API's bookkeeping on resume (found in live
    testing; this also protects the stock MCP write interceptor, which
    interrupts the same way).

Testing

  • 130 agent unit tests pass (cd agent && uv run pytest); TAP mode adds
    coverage for the policy-rule matcher and precedence, spoofed-header
    rejection, link origin validation, approval poll states (approved /
    denied / expired / pending), fail-closed fallbacks, and the read/write
    split table. All hermetic — no network.
  • Verified end-to-end against a live Slack workspace: reads, a held write
    approved from the TAP dashboard, the lazy credential-setup flow, denials
    failing closed, and a time-boxed approval grant.

Files

  • agent/tap_tools.pytap_discover, tap_call, tap_check_approval
    (stdlib-only)
  • agent/prompts/tap.py — system-prompt addendum (only added in TAP mode)
  • agent/internal_sources.py — per-service compose + boot diagnostics
  • agent/agent.py, agent/main.py — single-call steps; recursion budget
    carried into the AG-UI per-run config (with_config was being dropped)
  • docs/tap.md, setup.md, README.md, .env.example,
    .railway/railway.ts — setup docs, presented as a per-service choice at
    the credential step

Demo video to follow in a comment.

🤖 Generated with Claude Code

nanaknihal and others added 12 commits August 3, 2026 07:23
…scover + tap_call)

With TAP_AGENT_KEY set, the agent reaches Linear, Notion, PostHog, and any
other service connected to the team's TAP account (tap.human.tech) through
the TAP credential proxy instead of holding API keys in this process:

- tap_discover lists the credentials the agent may use, each with its
  approval policy and usage examples; tap_call makes one universal
  authenticated call (credential name + target URL + method + body). A new
  service needs no code here — an admin connects it in TAP and it is usable
  on the next message. A missing credential returns a prefilled setup link
  the bot relays in-channel.
- No keys in the process: TAP injects each credential server-side and pins
  it to its own API host. The direct MCP connections are skipped in TAP
  mode, so none of their tokens need to exist here.
- The stock write gate is preserved: a mutating tap_call emits the same
  confirm_write interrupt as the MCP interceptor before the request is sent
  (Linear GraphQL queries and Notion search/query POSTs count as reads;
  ambiguous calls count as writes). TAP's per-credential policy can
  additionally hold a call for a human approval, which the tool awaits.

Off by default: without TAP_AGENT_KEY, behavior is unchanged. No new
dependencies (stdlib urllib). tests/conftest.py clears ambient TAP_* env so
the suite stays deterministic on machines where TAP is configured.

Co-Authored-By: 0xZKnw <0xzknw@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n surface

Co-Authored-By: 0xZKnw <0xzknw@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Direct keys vs TAP mode is now a visible fork everywhere a deployer
actually decides how services get connected, instead of a trailing doc
section:

- setup.md: 'Optional sources' opens with the two options side by side
- Railway template: TAP_AGENT_KEY is a declared (preserved) variable, so
  the deploy screen shows the no-keys option next to LINEAR_API_KEY
- first boot: when direct keys load, one line notes the TAP alternative;
  when nothing is configured, the previously silent return now says how
  to connect sources either way

TAP stays opt-in and off by default; stock behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions

Setting TAP_AGENT_KEY no longer disconnects every direct MCP
integration. The tool set is the union: a service whose key is present
keeps its direct MCP connection (the deployer's explicit choice), and
tap_discover/tap_call cover everything else with no key in the process.
The prompt nudges the model to prefer a service's direct tools when both
exist, purely so the same action always takes the same path (a write
should not be TAP-held on some turns and not others). Boot log states
which services are direct and that the rest go through tap_call.

This is the incremental adoption path: move one sensitive service behind
TAP by deleting its key, keep low-stakes keys direct, no all-at-once
migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Security:
- Origin-check every human-facing TAP link before the model may relay it
  (credential setup links and approval links): only tap.human.tech,
  its subdomains, or the deployment's own proxy host survive; anything
  else is stripped with a warning. Closes the injected-model /
  hostile-upstream link-swap phishing seam.
- Treat a body-less GraphQL POST as a write (the query could ride in the
  URL); ambiguous still means confirm.
- Reject malformed header names (RFC 7230 token check) so no
  normalization quirk can smuggle a reserved X-TAP-* header.
- Require https for TAP_PROXY_URL except toward loopback — that URL
  receives the agent key.
- Docs now say plainly the in-channel gate is UX, TAP policy is the
  enforced boundary.

Usability:
- Surface the approval link + txn_id from TAP's 202 instead of
  discarding them, and add a tap_check_approval tool so a late approval
  is retrievable (timeout message says exactly what to do).
- Transport failures (connection refused, DNS, timeout) return a
  corrective proxy-unreachable message instead of crashing the turn.
- Default Content-Type: application/json when a body is present —
  urllib's form-urlencoded default 400ed Linear GraphQL reads.
- Boot-time TAP connectivity probe: startup log now shows available
  credential names, or a loud bad-key/unreachable diagnosis.
- Poll 404-after-202 reads as expired/resolved, not failure; an
  approved call with a failed upstream is reported as such.
- Prompt guidance for 401/403 (deployer problem, don't retry) and for
  who the setup link is for.

Docs/funnel:
- UTM parameters on all tap.human.tech links (readme/setup/env/docs).
- .env.example and setup.md Option B lead with the benefit (no pasted
  keys, injected agent can't leak what it never held) + free tier.
- Cut the stale pricing figure; note PostHog read-only parity
  difference; document TAP_APPROVAL_TIMEOUT=0; railway.ts comment now
  matches the compose model and preserves TAP_PROXY_URL/TIMEOUT.

85 agent tests pass (18 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A held call blocked the agent's turn for the full TAP_APPROVAL_TIMEOUT
(300s). That both leaves the chat user staring at a typing indicator and
overruns the runtime's HTTP body timeout — undici defaults to 300s, and
the stock runtime crashed with UND_ERR_BODY_TIMEOUT against the 300s
wait in live testing. 60s keeps fast approvals synchronous; past the
deadline the tool returns the approval link + txn_id and the outcome is
retrieved with tap_check_approval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generic tap_discover/tap_call turns take more graph steps than curated
MCP tools (discover, then compose the raw call, then corrective
retries), and the stock limit of 25 tripped mid-answer in live testing.
50 in TAP mode; stock stays at 25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…esume

confirm_write interrupts mid-step. With parallel tool calls, the
interrupt freezes the step before the sibling call's output is
recorded, and the resumed conversation fails the Responses API's
bookkeeping ('No tool output found for function call ...') — observed
live on the first gated tap_call write. Single-call steps sidestep the
class; this also protects the stock MCP write interceptor, which
interrupts the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The AG-UI layer constructs each run's RunnableConfig itself, so the
graph-level with_config recursion_limit never reaches LangGraph — runs
were failing at the stamped default of 25 despite the 50-step TAP-mode
budget. LangGraphAGUIAgent accepts a config merged into every run;
setting the limit there makes it actually apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A TAP-held write previously collected two approvals from the same
human: the in-channel confirm card, then TAP's server-side approval.
tap_call now evaluates the credential's declared policy rules (the
same require-over-auto, url-over-method semantics TAP documents,
mirrored in _pattern_matches/_tap_will_hold) and skips the card exactly
when TAP will hold the call — the enforced gate is the single gate.
Auto-approved routes keep the in-channel card, so every write still
gets one human approval. Any doubt (proxy unreachable, unknown
credential, malformed rules) fails closed to showing the card.

An active time-boxed grant may auto-approve a call predicted as held;
that is TAP's own semantics — the grant is a human-authored
pre-approval.

19 new tests: matcher table, rule-precedence cases, fail-closed
variants, and the three tap_call behaviors (skip card when held, card
when auto-approved, card when rules unavailable). 104 total pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream added a GitHub MCP direct integration and renamed the deploy
section; TAP-mode copy updated accordingly (GitHub joins the composable
direct sources; example list of no-integration services adjusted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erate it as a list

TAP renders /agent/services URL-override rules with "methods": "ANY"
(a string), while method rules carry a list. Iterating the string
yielded characters, silently dropping every URL-override rule from
_tap_will_hold. For a credential with an auto-approve URL override plus
default method gating, that mispredicted "held" — skipping the
in-channel card for a call TAP then auto-approved: a write with no
human gate anywhere. _rule_covers_method now handles string-"ANY",
list, and list-containing-"ANY"; the precedence tests mirror the real
rendered shape instead of a fabricated list form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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