Skip to content
Open
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
207 changes: 207 additions & 0 deletions docs/v0.4-baseline-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# v0.4 baseline findings: approval and session boundaries

Date observed: 2026-08-16
Revision: `55ff730` (`feat/agent-approval-plane`, identical to `origin/main` at observation time)

## Scope and method

These are behavioral baseline experiments, not a proposed implementation. They used only local ASGI/MCP clients, an in-memory SQLite database, and fake pool/session objects. No real SSH server or external service was contacted. Production and test sources were not modified.

Interfaces exercised:

- `fastapi.testclient.TestClient` against the unified service returned by `create_service_app()`;
- `fastmcp.Client` against a real in-memory `FastMCP` instance with registered Shuttle tools;
- real `ConfirmTokenStore` and tool registration/execution flow;
- stateful fake `SessionManager` and fake node repository, so session selection could be observed without SSH.

For reproducibility, the focused pre-existing tests were also run:

```text
.venv/bin/pytest -q \
tests/test_mcp/test_fastmcp_client.py::test_run_confirm_flow_via_client \
tests/test_mcp/test_service_app.py::test_create_service_app_rejects_bad_bearer \
tests/test_mcp/test_tools.py::test_implicit_session_reused_across_calls

... [100%]
```

## Finding 1: the service API token protects `/api`, not `/mcp`

A unified app was created with `api_token="baseline-secret"`. The pool and session manager were replaced with local fakes, and startup used in-memory SQLite.

### Exact observations

| Probe | Authorization | HTTP status | Observed response |
|---|---|---:|---|
| `GET /api/stats` | none | 401 | `{"detail":"Invalid or missing token"}` |
| `GET /api/stats` | `Bearer wrong` | 401 | `{"detail":"Invalid or missing token"}` |
| `GET /api/stats` | `Bearer baseline-secret` | 200 | `{"node_count":0,"active_sessions":0,"total_commands":0}` |
| valid MCP `initialize` to `POST /mcp/` | none | 200 | MCP initialization result; server name `shuttle`; an `mcp-session-id` was issued |
| valid MCP `initialize` to `POST /mcp/` | `Bearer wrong` | 200 | same successful MCP initialization shape; a different `mcp-session-id` was issued |

The successful unauthenticated MCP response began:

```text
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26",...
```

**Current boundary:** possession of the web/API bearer token is required for `/api/*`, but is neither required nor validated for `/mcp/*`. An invalid bearer header does not cause MCP rejection.

**Security implication:** anyone who can reach the MCP listener can initialize an MCP session and invoke its tools. The API token currently is a web-control-plane credential, not a service-wide or MCP authorization credential.

## Finding 2: a CONFIRM challenge can be self-approved by the same MCP caller

A real `FastMCP` instance was configured with the real `ConfirmTokenStore`, a guard that classified `sudo id` as CONFIRM, and a fake session executor. A single MCP client called `ssh_run` twice.

### Exact observations

First call (`ssh_run(command="sudo id", node="node-a")`) returned a bearer-like secret directly to the caller (actual random value redacted here; observed length was 43 characters):

```text
⚠️ Confirmation required
Command: sudo id
Rule: operator approval required

To proceed: ssh_run(command="sudo id", node="node-a", confirm_token="<TOKEN>")
```

The same client copied that token into its next tool call. Observed result:

```text
EXECUTED sid=session-1 command=sudo id
```

The fake executor's event log confirmed actual execution:

```text
["session-1", "sudo id"]
```

Replaying the same token returned:

```text
Error: invalid or expired confirmation token.
```

**Current boundary:** token validity binds to `(command, node)` and is one-time/TTL constrained, but not to an approving principal, transport session, or channel. The challenge and approval capability are delivered to the same caller through the same tool response. Therefore CONFIRM is a two-call acknowledgement, not independent human/operator approval.

## Finding 3: implicit execution sessions are keyed only by node in one server process

A stateful fake session manager recorded all creates and executes. Calls were sent through the real FastMCP tool protocol.

### Same client, two nodes

Call sequence and outputs:

```text
node-a / pwd -> EXECUTED sid=session-1 command=pwd
node-b / pwd -> EXECUTED sid=session-2 command=pwd
node-a / whoami -> EXECUTED sid=session-1 command=whoami
```

Recorded creates and active sessions:

```text
creates: ["node-a", "node-b"]
active_sessions: [["session-1", "node-a"], ["session-2", "node-b"]]
```

Thus node A reused its first session while node B received a separate session.

### Two distinct MCP clients, same node

Client A connected, invoked `ssh_run`, disconnected; then client B made a separate MCP connection and invoked `ssh_run` for the same node. Exact observation:

```json
{
"active_sessions": [["s-1", "node-a"]],
"client_a_output": "s-1",
"client_b_output": "s-1",
"events": [
["create", "node-a", "s-1"],
["execute", "from-client-a", "s-1"],
["execute", "from-client-b", "s-1"]
]
}
```

**Current boundary:** automatic lookup chooses the first active in-memory session whose `node_id` equals the resolved node. It has no caller/client/transport identity dimension. Consequently, independent MCP clients targeting the same node share working-directory state and session bypass patterns. There can also be at most one automatically selected session per node under the normal tool path; if multiple active sessions exist, list order decides which is used.

## Baseline conclusion

The current effective model is:

1. **Authentication:** web `/api/*` bearer authentication only; MCP is reachable without that credential.
2. **Confirmation:** one caller obtains and redeems its own one-time command/node token.
3. **Session isolation:** process-local automatic sessions are selected by node, and therefore shared across callers targeting that node.

These are objective descriptions of v0.3.1 behavior, not assertions that the behavior is intended for v0.4.

## Proposed v0.4 acceptance tests

The expected policy should be made explicit before implementation. For an approval plane whose goal is authenticated agents, independent approval, and per-agent isolation, the following tests are recommended.

### A. Authentication boundary

1. **MCP rejects missing credentials**
- Build the unified ASGI app with a configured agent/API credential.
- Send a valid MCP `initialize` request to `/mcp/` without credentials.
- Expect `401` (or the selected protocol-auth failure), no MCP session ID, and no initialized session.

2. **MCP rejects invalid credentials**
- Repeat with an invalid bearer credential.
- Expect rejection, not successful initialization.

3. **MCP accepts a valid agent credential and propagates identity**
- Initialize and call a harmless fake-backed tool with a valid credential.
- Expect success and verify the authenticated principal is available to authorization, audit logging, confirmation, and session selection.

4. **Web and MCP credential policy is explicit**
- If credentials are intentionally separate, prove a web-only token cannot invoke MCP and an agent-only token cannot mutate web administration routes.
- If intentionally shared, prove the same configured token is enforced consistently on both route families.

### B. Independent confirmation

5. **Requesting agent cannot self-approve from the challenge response**
- Agent A requests a CONFIRM-level command.
- The response may expose a challenge/request ID, but must not expose a credential sufficient for Agent A to execute it.
- Repeating the command or echoing the request ID without approval must not execute.

6. **Only an authorized approver can approve**
- Agent A creates a pending request.
- Agent A's credential cannot approve it.
- Approver B, with the required role/capability, approves it through the approval interface.
- Agent A can then execute exactly the approved command on exactly the approved node.

7. **Approval is bound and one-use**
- Approval for `(requester A, command X, node N)` must fail for command Y, node M, or requester C.
- First valid execution succeeds; replay fails; expiry fails.

8. **Denial and audit are observable**
- Approver denial prevents execution.
- Audit evidence records requester, approver, decision, command digest/value, node, timestamps, and final execution outcome without relying on caller-supplied identity.

### C. Session ownership and isolation

9. **Same agent + same node reuses state**
- Agent A executes two commands on node N using a stateful fake.
- Expect the same session ID and preserved working directory/bypass state.

10. **Different agents + same node do not share state**
- Agent A changes directory or obtains a session-scoped bypass on node N.
- Agent B then targets node N.
- Expect a different session ID, default working directory, and no inherited bypass.

11. **Same agent + different nodes gets distinct sessions**
- Agent A targets nodes N and M.
- Expect separate session IDs and state.

12. **Transport reconnect policy is explicit**
- Reconnect the same authenticated agent and assert either stable reuse by durable agent identity or deliberate reset, according to policy.
- A transport-generated MCP session ID alone should not silently redefine ownership unless that is the documented contract.

13. **Concurrent selection is deterministic**
- Run simultaneous first calls for the same `(agent, node)` and assert one owned session is selected/created (or an explicitly supported multiplicity model), with no list-order ambiguity.

All tests can remain hermetic by using TestClient/ASGI transport, FastMCP's in-memory client, in-memory SQLite, and stateful fake executors; none requires a real SSH endpoint.
44 changes: 44 additions & 0 deletions skills/shuttle/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
name: shuttle
description: Use Shuttle for authenticated, policy-controlled remote operations.
---

# Using Shuttle

Use Shuttle when an AI agent needs to operate existing SSH nodes through a central policy, approval, and audit gateway.

## Core workflow

1. Inspect available nodes with `ssh_list_nodes` and select the exact requested node. Never substitute another host silently.
2. Begin with read-only inspection whenever possible.
3. Run commands with `ssh_run`. Shuttle derives actor, client, and conversation identity from the authenticated MCP request context.
4. Handle the result according to its policy decision:
- `ALLOW` / `WARN`: inspect and report the result.
- `BLOCK`: stop; do not bypass it.
- `PENDING_APPROVAL`: report the exact node and command, then wait for a human decision in the Shuttle Web Approvals page.
5. After approval, retry the identical command with its `approval_id`. An approval is bound to the authenticated requester, MCP session, node, and exact command; it is single-use and expires.
6. Report the command result together with its approval or audit ID when present.

## Sessions

- Shuttle automatically preserves remote working-directory state within the authenticated MCP session.
- Do not try to provide or override actor, client, or conversation identifiers.
- A reconnect may create a new conversation boundary; do not assume prior approvals or session state carry over.

## File operations

- Use `ssh_upload` and `ssh_download` only after verifying the local path, remote path, node, and overwrite impact.
- Do not place secrets in command text or ordinary files when a credential mechanism is available.
- Treat node creation and file transfer as privileged operations even when no shell command is involved.

## Authentication and host trust

- HTTP MCP uses an Agent token distinct from the Web/operator token.
- Never expose the Web/operator token to an agent.
- Shuttle requires SSH host-key verification. Register the host key in the trusted `known_hosts` file before connecting; never disable checking with `known_hosts=None`.

## Failure handling

- Use bounded timeouts for commands that may hang.
- On connection or host-key errors, stop and report the exact failure instead of weakening verification.
- If an approval is denied, expired, consumed, or mismatched, request a new approval rather than altering identifiers or replaying it.
59 changes: 59 additions & 0 deletions src/shuttle/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import json
import secrets
from pathlib import Path

Expand Down Expand Up @@ -81,6 +82,14 @@ def serve(
token_path.write_text(api_token)
token_path.chmod(0o600)

agent_token_path = config.shuttle_dir / "agent_token"
if agent_token_path.exists():
mcp_token = agent_token_path.read_text().strip()
else:
mcp_token = secrets.token_urlsafe(32)
agent_token_path.write_text(mcp_token)
agent_token_path.chmod(0o600)

from rich.console import Console
from rich.panel import Panel
from rich.text import Text
Expand All @@ -93,6 +102,8 @@ def serve(
info.append(f"http://{host}:{port}\n", style="cyan")
info.append(" API token ", style="dim")
info.append(api_token, style="green bold")
info.append("\n Agent token ", style="dim")
info.append(mcp_token, style="cyan bold")
console.print(
Panel(
info,
Expand All @@ -111,6 +122,7 @@ async def _run():
host=host,
port=port,
api_token=api_token,
mcp_token=mcp_token,
db_url=db_url,
)

Expand Down Expand Up @@ -771,6 +783,53 @@ async def _import() -> None:
# ── Config commands ───────────────────────────────────────────────────────────


@app.command("approvals")
def approvals_list(
status: str = typer.Option("pending", help="Approval status to list"),
json_output: bool = typer.Option(False, "--json", help="Emit stable JSON"),
) -> None:
"""List approval requests for agents and operators."""
from shuttle.core.config import ShuttleConfig
from shuttle.db.engine import create_db_engine, create_session_factory, init_db
from shuttle.db.repository import ApprovalRepo

async def _list() -> list[dict]:
config = ShuttleConfig()
engine = create_db_engine(config.db_url)
await init_db(engine)
factory = create_session_factory(engine)
try:
async with factory() as session:
items = await ApprovalRepo(session).list(status)
return [
{
"id": item.id,
"status": item.status,
"node": item.node_name,
"command": item.command,
"actor_id": item.actor_id,
"client_id": item.client_id,
"conversation_id": item.conversation_id,
"expires_at": item.expires_at.isoformat(),
}
for item in items
]
finally:
await engine.dispose()

items = asyncio.run(_list())
if json_output:
typer.echo(json.dumps({"items": items}, ensure_ascii=False))
return
if not items:
typer.echo("No approval requests.")
return
for item in items:
typer.echo(
f"{item['id']} {item['status']} {item['node']} {item['actor_id']} {item['command']}"
)


@config_app.command("show")
def config_show() -> None:
"""Show current Shuttle configuration."""
Expand Down
15 changes: 9 additions & 6 deletions src/shuttle/core/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

import asyncssh

Expand Down Expand Up @@ -47,7 +48,9 @@ class NodeConnectInfo:
port: int = 22
password: str | None = None
private_key: str | None = None
known_hosts: str | None = None
known_hosts: str | None = field(
default_factory=lambda: str(Path.home() / ".ssh" / "known_hosts")
)
jump_host: NodeConnectInfo | None = None
connect_timeout: float = 30.0
extra_options: dict = field(default_factory=dict)
Expand Down Expand Up @@ -99,11 +102,11 @@ def _build_connect_kwargs(info: NodeConnectInfo) -> dict:
if info.private_key is not None:
kwargs["client_keys"] = [asyncssh.import_private_key(info.private_key)]

if info.known_hosts is not None:
kwargs["known_hosts"] = info.known_hosts
else:
# Disable host-key verification when no known_hosts is provided.
kwargs["known_hosts"] = None
if info.known_hosts is None:
raise ValueError(
"known_hosts is required; configure a trusted file instead of disabling host-key verification"
)
kwargs["known_hosts"] = info.known_hosts

# Forward any caller-supplied overrides last so they can override defaults.
kwargs.update(info.extra_options)
Expand Down
Loading
Loading