Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ package versions for a release.

## Unreleased

- **Breaking (MCP): `"prompt"`-policy consent is elicitation-only.** The Claude Code-specific
fallback is gone: `tools/list` no longer emits `_meta["anthropic/requiresUserInteraction"]`, and
the MCP server no longer sends `consent: "client"`. A `"prompt"` tool called from an MCP client
that doesn't declare the `elicitation` capability is now denied with `policy_denied` (reason
`no_consent_channel`), the same as the CLI. Current Claude Code declares elicitation, so it gets
the elicitation prompt instead; only a client that relied on the flag without supporting
elicitation loses access. To fix that, use a client that supports elicitation, or set the tool's
policy to `"allow"` in `config.json` — which removes the gate for every caller, including the
CLI — and restart the daemon (`appduct daemon stop`; the next command starts it again), since
`config.json` is read once at daemon start.
- A daemon with this change that receives `consent: "client"` from an older MCP server treats it
as no consent, so the call is denied and audited as `no_consent_channel`.
- `@appduct/shared`: `ToolsCallParams.consent` and the audit record's `consent` narrow to
`"elicitation"`. New audit records never carry `"client"`; existing audit files may.

- **`config.json`'s `wssPort` accepts `0`, meaning "bind an OS-assigned port".** The pinned-wss
listener takes whatever ephemeral port the OS hands it, and everything that reports or advertises
the port — `daemon.status`'s `wssPort`, a minted link's `endpoint.port`, and so the deep link and
Expand Down
88 changes: 36 additions & 52 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ Deliberately out of scope, so the boundaries of the design are explicit:
- Arbitrary code execution inside the app — only pre-registered, named tools.
- Anonymous or unauthenticated remote access.
- Treating deep links as proof of authority.
- A general-purpose interactive consent UI. `policy: "prompt"` (§12) exists, but its only two
implemented gates are both MCP-only: an MCP client that declares the `elicitation` capability
(preferred) or one that enforces `_meta["anthropic/requiresUserInteraction"]` (fallback) —
every other caller (the CLI, an MCP client on neither channel) fails closed rather than getting
- A general-purpose interactive consent UI. `policy: "prompt"` (§12) exists, but its only
implemented gate is MCP elicitation: an MCP client that declares the `elicitation` capability —
every other caller (the CLI, an MCP client without elicitation) fails closed rather than getting
a prompt of its own.
- Remote relay to hosts outside the operator machine.
- Pinning an offline anchor CA that signs short-lived leaf certs. The current model pins
Expand Down Expand Up @@ -263,7 +262,7 @@ Methods:
| `sessions.describe` | `{ selector? }` | full session detail incl. device metadata, state timestamps, tool count |
| `sessions.revoke` | `{ selector? }` | `{ ok: true }` — closes socket (code 1000), frees alias |
| `tools.list` | `{ selector?, filter?, limit?, offset? }` | `{ tools: ToolsListEntry[], total }` — `tools` is the registry sorted by `name` (code-point order), `filter`ed (case-insensitive substring match against name/description) and paged with `limit`/`offset`; each entry is a `ToolDescriptor` (full schema + annotations) plus the tool's effective `policy: "allow" \| "deny" \| "prompt"` (§12), resolved daemon-side. `total` is the filtered count *before* paging, so a caller can tell how much a page left out |
| `tools.call` | `{ selector?, name, args, timeoutMs?, caller?: "cli" \| "mcp", consent?: "client" \| "elicitation" }` | `{ result, callId }` on success — `callId` lets a caller with several in-flight calls match `tool_call_progress`/`tool_call_finished` events back to this call; JSON-RPC error with `data.type` preserving the wire error type on failure. `caller` attributes the audit record (§12); `consent` is the MCP server's evidence of a `"prompt"`-policy human gate (§12) — `"client"` (the flag-based gate) or `"elicitation"` (the elicitation-based gate), absent for the CLI. |
| `tools.call` | `{ selector?, name, args, timeoutMs?, caller?: "cli" \| "mcp", consent?: "elicitation" }` | `{ result, callId }` on success — `callId` lets a caller with several in-flight calls match `tool_call_progress`/`tool_call_finished` events back to this call; JSON-RPC error with `data.type` preserving the wire error type on failure. `caller` attributes the audit record (§12); `consent` is the MCP server's evidence of a `"prompt"`-policy human gate (§12) — `"elicitation"` after the client accepted an elicitation prompt, absent otherwise (including for the CLI). |
| `tools.cancel` | `{ selector?, callId, reason? }` | `{ cancelled: boolean }` — sends `tool_cancel` (§7) to the app for a still-pending call; `false` for an unknown/already-finished `callId` or no active socket (a no-op, not an error) |
| `events.subscribe` | `{ sessionSelector?, kinds? }` | `{ ok: true }`, then `event` notifications on this connection |
| `events.since` | `{ selector?, since?, kinds?, limit? }` | `{ events: EventNotification[], cursor }` — pull counterpart to `events.subscribe`, draining the per-session retention buffer described below |
Expand Down Expand Up @@ -467,20 +466,14 @@ proxies daemon RPC (auto-spawning the daemon like any client):
`notifications/tools/list_changed`, so an agent's tool list tracks the device.
- Tool calls, progress frames, errors (with their `type` preserved), and descriptor
annotations all map through verbatim. Two semantics the MCP surface does add:
`"prompt"`-policy consent (§12) — two channels, preferred in this order per connection:
(1) elicitation (issue #10), whenever the client declared the `elicitation` capability at
`initialize`: a `"prompt"`-policy call sends one `elicitation/create` request naming the
`"prompt"`-policy consent (§12) — one channel, elicitation (issue #10), used whenever the
client declared the `elicitation` capability at `initialize`: a `"prompt"`-policy call sends one `elicitation/create` request naming the
tool, the session alias, and the call's arguments, and an `action: "accept"` reply becomes
`consent: "elicitation"` on `tools.call`; a decline/cancel/timeout short-circuits to an MCP
tool result with `isError: true` without ever reaching the daemon; a failed request (declared
but rejected as unsupported, a transport error) is treated as no consent, never as approval.
(2) the flag-based fallback (issue #14), only reachable when the client didn't declare
elicitation: `tools/list` emits `_meta["anthropic/requiresUserInteraction"]` for a tool whose
effective policy is `"prompt"`, gated on the connected client's `initialize` `clientInfo`, and
`tools/call` echoes that gate back to the daemon as `consent: "client"`, only for a tool this
same connection's most recent listing actually flagged. The two channels never both arm for
the same call: whenever elicitation is preferred, the flag is never emitted at all. And
cancellation: an MCP client's `notifications/cancelled` maps to `tools.cancel` (§5), only for
A client that didn't declare elicitation gets no consent marker, so the daemon denies its
`"prompt"` calls. And cancellation: an MCP client's `notifications/cancelled` maps to `tools.cancel` (§5), only for
a call that requested progress (the SDK only assigns a `progressToken`, and only the
progress-tracked path opens the dedicated connection that learns `callId` while the call is
still in flight; a non-progress call has no `callId` to cancel by until it has already
Expand Down Expand Up @@ -884,9 +877,9 @@ it.
`policy.tools["<alias>/<name>"]`, each `"allow" | "deny" | "prompt"`. `allow`/`deny`
behave as before; denied calls return `policy_denied` and are audited.
- `"prompt"` means "a human gate is required; if one cannot be guaranteed, deny" — it
fails closed rather than silently behaving like `allow`. Two gates are implemented today,
both MCP-only, tried in this order per connection (issues #10 and #14):
1. **Elicitation** (issue #10): whenever the connected client declared the `elicitation`
fails closed rather than silently behaving like `allow`. One gate is implemented today, and
it is MCP-only:
- **Elicitation** (issue #10): whenever the connected client declared the `elicitation`
capability at `initialize` (checked via the SDK Server's `getClientCapabilities()`), a
`"prompt"`-policy call sends one `elicitation/create` request — message naming the tool,
the session alias, and the call's arguments (JSON, truncated past a small bound) — and
Expand All @@ -899,24 +892,15 @@ it.
unsupported anyway, or a transport error — that is treated as **no consent obtained**,
never as approval: the call falls through to the daemon with no `consent` marker, landing
on the same `policy_denied`/`no_consent_channel` path as any other ungated caller.
2. **The flag-based fallback** (issue #14), reached only when the client did **not** declare
elicitation: `tools/list` emits `_meta["anthropic/requiresUserInteraction"] = true` for a
`"prompt"` tool, per connection, only when the connected client's `initialize`
`clientInfo` is known to enforce it (Claude Code ≥ v2.1.199 — every other client ignores
the flag). `tools/call` then sets `consent: "client"` only for a tool this same
connection's most recent `tools/list` actually flagged that way — not merely a tool whose
live policy happens to be `"prompt"` on a client that happens to qualify — so a call can't
ride on a stale or hypothetical listing.

The two channels are mutually exclusive per connection, never both armed for the same call:
whenever a client declares elicitation, `tools/list` never emits the `requiresUserInteraction`
flag for it, so a "prompt" tool can't trigger two consent prompts for one call. Every caller on
neither channel (the CLI, an MCP client that declares neither capability) is denied with
`policy_denied`, reason `no_consent_channel`.
- Elicitation is the stronger of the two: it carries an *observed decision* (the client's
reply to a specific request), where the flag is only evidence the client *armed itself* to
ask — the daemon never sees the client's own prompt UI either way. `clientInfo` and a
client's declared capabilities are self-reported, and `consent` is an ordinary RPC param on

Every other caller (the CLI, an MCP client that doesn't declare elicitation) is denied with
`policy_denied`, reason `no_consent_channel`. An earlier Claude Code-specific fallback that
emitted `_meta["anthropic/requiresUserInteraction"]` on `tools/list` and sent
`consent: "client"` was removed: it was evidence only that a client armed itself to ask, not
an observed decision, and it tied consent to one client's self-reported `clientInfo`.
- Elicitation carries an *observed decision* (the client's reply to a specific request), but
the daemon never sees the client's own prompt UI. A client's declared capabilities are
self-reported, and `consent` is an ordinary RPC param on
`daemon.sock` — any local process that can reach the socket (the CLI, or an agent with
shell access, which is the typical Claude Code setup this feature targets) can set it
directly, same as it could send any other RPC call. `"prompt"` guards against a compliant
Expand All @@ -927,21 +911,22 @@ it.
`"decline"` or `"cancel"` without ever really surfacing the prompt to a human (older Codex
behavior at the time of writing). This fails closed — the tool is simply never callable
through that client — which is the acceptable failure mode; it is not distinguishable from
a human genuinely saying no.
- Two client-observable behaviors worth documenting rather than filing as bugs:
non-interactive Claude Code (`--permission-prompt-tool`) converts an `allow` result
for a flagged tool into a denial (`MCP tool requires user interaction; not
supported via --permission-prompt-tool`) — that conversion is the client's, not
the daemon's. And `"prompt"` denies unconditionally in any unattended pipeline
(CI has no consent channel at all); pipelines that need a tool to run
a human genuinely saying no. Non-interactive Claude Code (`claude -p`) behaves this way: it
declares elicitation and answers every request with `"cancel"`.
- The opposite also exists: a client can be configured to accept elicitations automatically
(Claude Code's `Elicitation` hook can answer without showing a dialog). That approval is the
user's own client configuration, and the daemon cannot tell it apart from a person
accepting.
- `"prompt"` denies unconditionally in any unattended pipeline (CI has no consent channel at
all); pipelines that need a tool to run
unattended must set `allow`/`deny` explicitly for it rather than `"prompt"`.
- Audit: every `tools.call` appends one JSONL record to `audit/<date>.jsonl`:
`{ ts, sessionId, alias, tool, argsSha256, outcome: "ok"|"error"|"denied"|"cancelled",
errorType?, durationMs, caller: "cli"|"mcp"|"client", consent?: "client"|"elicitation" }`.
`consent` is set only when a `"prompt"` call proceeded on one of the two channels above, and
its value names which one — `"client"` for the flag-based gate, `"elicitation"` for an
observed accept — kept distinct from a plain `"ok"` since the daemon never observes either
channel's client-side behavior itself, only that the call arrived carrying this marker. Raw
errorType?, durationMs, caller: "cli"|"mcp"|"client", consent?: "elicitation" }`.
`consent` is set only when a `"prompt"` call proceeded after an elicitation accept, kept
distinct from a plain `"ok"` since the daemon never observes the client-side prompt itself,
only that the call arrived carrying this marker. Audit files written before the flag-based
fallback was removed may also contain `consent: "client"`. Raw
args are never logged. Day files are pruned on the `auditRetentionDays` schedule described in
§3, and `daemon status` surfaces the directory's file count, size, and failure counters.

Expand Down Expand Up @@ -984,10 +969,9 @@ named-pipe path `\\.\pipe\appduct-<user>` behind the same client API.

## 14. Current limitations

- A general-purpose interactive consent UI. `policy: "prompt"` (§12) has two implemented
MCP-only gates (elicitation, issue #10; the `requiresUserInteraction` flag, issue #14) — the
CLI, and an MCP client that declares neither channel, still fail closed with no prompt of
their own.
- A general-purpose interactive consent UI. `policy: "prompt"` (§12) has one implemented
MCP-only gate (elicitation, issue #10) — the CLI, and an MCP client that doesn't declare
elicitation, still fail closed with no prompt of their own.
- Remote relay / hosts outside the operator machine.
- Pinning an offline anchor CA that signs short-lived leaves (rotation uses overlapping
pin sets; the anchor-CA design is a future option).
Expand Down
22 changes: 12 additions & 10 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,31 +353,33 @@ not as the mechanism that keeps a destructive tool out of reach of a hostile one
`appduct/client` alike — is evaluated against this before it ever reaches the app;
a denial returns `policy_denied` and never sends a `tool_call` frame. `"prompt"` requires a human gate
and fails closed everywhere one can't be guaranteed: today the only implemented gate
is an MCP client that enforces `_meta["anthropic/requiresUserInteraction"]` (Claude
Code ≥ v2.1.199); the CLI and every other client are denied outright
is an MCP client that declares the `elicitation` capability, which receives an
`elicitation/create` prompt for each call; the CLI and every other client are denied outright
(`policy_denied`, reason `no_consent_channel`) rather than silently treated as
`"allow"`. That gate is *not* a defense within this feature's own trust boundary: the
daemon trusts the MCP server's `consent: "client"` param verbatim rather than
daemon trusts the MCP server's `consent: "elicitation"` param verbatim rather than
re-deriving it, so any local process that can reach `daemon.sock` — including the CLI,
or an agent with shell access, which is the typical Claude Code setup this feature
targets — could send that param directly, the same way it could send any other RPC
call. This is consistent with, not an exception to, the trust boundary above: anything
that can reach the socket already has full daemon control. `"prompt"` guards against a
compliant MCP client silently auto-approving on the caller's behalf, not against a
hostile process on the operator's own machine. `clientInfo` is also self-reported, so
it's not a defense against a hostile client claiming to be a compliant one either —
only against a compliant client's own auto-approval. Two behaviors worth knowing about
rather than filing as bugs: non-interactive Claude Code (`--permission-prompt-tool`)
converts an `allow` result for a flagged tool into a denial — that conversion is the
client's, not the daemon's; and `"prompt"` denies unconditionally in CI or any other
hostile process on the operator's own machine. The prompt reaches whatever the client
does with elicitations: a client configured to answer them automatically (Claude Code's
`Elicitation` hook, for example) approves without showing anything, and the daemon cannot
tell that apart from a person accepting. A client's declared capabilities are
also self-reported, so it's not a defense against a hostile client that answers the
prompt itself either — only against a compliant client's own auto-approval. Worth
knowing rather than filing as a bug: `"prompt"` denies unconditionally in CI or any other
unattended pipeline (there is no consent channel there at all), so a pipeline that
needs a tool to run unattended must set `allow`/`deny` for it explicitly. Until a
non-MCP consent channel ships, `"deny"` remains the only way to hard-block a tool for
CLI callers.
- **Audit.** Every `tools.call` attempt — regardless of outcome — appends one line to
`audit/<YYYY-MM-DD>.jsonl`: timestamp, session, alias, tool name, a sha256 of the
canonicalized args (never the raw args), outcome, error type if any, duration, caller
(`cli`/`mcp`/`client`), and — only for a `"prompt"` call that proceeded — `consent: "client"`.
(`cli`/`mcp`/`client`), and — only for a `"prompt"` call that proceeded — `consent: "elicitation"`
(files written by older versions may also contain `consent: "client"`).
That marker is the weakest form of evidence recorded here: the daemon never observes
the actual consent decision, only that the call arrived already gated, so it's kept
distinct from a plain `"ok"` rather than folded into it. This is on unconditionally;
Expand Down
4 changes: 2 additions & 2 deletions packages/appduct/src/__tests__/call-timeouts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("toMcpTool", () => {
const toMcpTool = createMcpToolMapper(() => {});

test("never emits a timeout on the MCP tool, even for a tool that declares one", () => {
const mapped = toMcpTool(namespacedTool(60_000), false);
const mapped = toMcpTool(namespacedTool(60_000));

// The deadline is a daemon-side scheduling hint, not part of the MCP `Tool` contract. This
// guards against a future refactor swapping the explicit field mapping for a spread — under
Expand All @@ -112,7 +112,7 @@ describe("toMcpTool", () => {
});

test("maps a tool that declares one identically to a tool that does not", () => {
expect(toMcpTool(namespacedTool(60_000), false)).toEqual(toMcpTool(namespacedTool(), false));
expect(toMcpTool(namespacedTool(60_000))).toEqual(toMcpTool(namespacedTool()));
});
});

Expand Down
Loading
Loading