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 .changeset/peek-share-session-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@peekdev/mcp': minor
---

Add `share_session` MCP tool — consent-gated session bundle export.

`share_session(sessionId, surface?)` elicits an explicit egress consent card
(naming what is exported and where) before producing a portable `.peekbundle`
temp file from a recorded session. On deny → `{ ok: false, result: 'denied' }`,
no file written. On approve → `{ ok: true, bundlePath, filename, sizeBytes, caveat }`.
The bundle contains the masked session recording (DOM + console/network events).
Every approved export is recorded to `~/.peek/audit.log` as `tool: share_session`
(session ID + surface; bundle bytes are never written to the audit log).
Designed for connector-driven upload flows (e.g. Slack `@peek share this session`);
the connector is responsible for uploading and deleting the temp file.
81 changes: 81 additions & 0 deletions docs/peek/THREATMODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ project's pre-launch supply-chain hygiene controls (see
8. **Chrome Web Store update channel** — once published, CWS pushes
updates to all installs. Trust boundary: Cubenest publisher account
→ all extension users.
9. **`share_session` connector upload** — the one path where a recorded
session bundle leaves the local store and is uploaded to a third-party
cloud (e.g. Slack via `files.uploadV2`). Trust boundary: peek local
store → third-party cloud service.

## Mitigations already in place

Expand Down Expand Up @@ -183,6 +187,83 @@ independently auditable.
| `consentDelegated` flag on local socket | Malicious local process forges flag to bypass Level-3 local banner for non-destructive actions | `mitigated` (SP4 pairing) | SW now also requires a valid pairing secret; an unpaired process falls through to the local banner. Unconditional destructive guard + TOCTOU re-check + Level ≥ 3 precondition still apply. |
| Connector secret at rest (OS keychain by default; `0600` file fallback) | Local attacker reads the keychain entry / fallback file and presents the secret | `accepted` (local-peer-trust class) | Defends against unpaired processes, not a fully compromised user account. SP6a moved the secret to the OS keychain by default; `--insecure-store` selects the `0600` file. |

## Session egress — `share_session` (SP5 / connector upload)

SP5 introduces the **one path where recorded session data leaves peek's
local-first store for a third-party cloud**. The `share_session` MCP tool
exports a recorded session as a `.peekbundle` and hands it to the active
connector for upload to its chat surface (e.g. a Slack thread via
`files.uploadV2`).

### What the bundle contains

A `.peekbundle` is a portable archive of the session's rrweb event stream —
DOM snapshots, console events, and network events. The content is **masked
at capture time** by `peek-extension/src/relay/mask.ts` before it ever reaches
the local store. The masking is a capture-time transformation, not a security
redaction: the bundle contains real session activity, just with PII fields
replaced per the masking rules in effect when the session was recorded. It is
not a sanitised summary.

### Consent gate

`share_session` never runs silently. Before producing any file the tool
presents an explicit egress consent card naming what is being exported and
where. The card uses the same elicitation consent mechanism as
`execute_action` (SP3b) and is independent of the Level-3 act gate: a user
at Level 1 can deny `execute_action` entirely and still encounter the
`share_session` consent card when the connector requests an upload. On
deny, no file is written and the tool returns `{ ok: false, result: 'denied' }`.

### Slack upload path

When the connector is `@peekdev/connector-slack`, the approved bundle is
uploaded to Slack via the `files.uploadV2` API. This requires the
`files:write` OAuth scope on the Slack app. The upload is made by the
connector process using the `xoxb` bot token stored in the OS keychain
(SP6b-1). peek itself does not hold or transmit the Slack token; the
connector receives the `bundlePath` from `share_session` and performs the
upload independently.

### Temp-file lifecycle

The `.peekbundle` is written to a process-scoped temp path with a random
nonce suffix. After the connector reports upload success or failure, the
connector-core layer deletes the temp file. The deletion is best-effort on
failure paths, and the temp path is in the OS temp directory (not
`~/.peek/`), so it is subject to normal OS temp-dir cleanup if the process
exits abnormally before deletion.

### Audit log

Every approved export is recorded to `~/.peek/audit.log` (mode `0600`) as
`tool: share_session`, including the session ID, the surface destination,
and the approver tag (e.g. `connector-elicit` for a delegated-consent
approval). The bundle bytes and the bundle path are never written to the
audit log.

### Residual limits (honest framing)

- **Once the bundle is in Slack it is under Slack's retention and access
controls**, not peek's. peek's local-first guarantees do not extend past
the upload boundary. Workspace admins, eDiscovery exports, and Slack's
own data-retention policies apply to the uploaded file.
- **Masking is capture-time, not security redaction.** If the masking
configuration at record time was incomplete (e.g. a custom input field
not covered by the default mask rules), the unmasked value may be in the
bundle. Users should treat the uploaded bundle with the same care as the
original session.
- **The consent card names the destination surface but not every downstream
recipient.** In a public Slack channel the uploaded file is visible to all
channel members. peek cannot enumerate channel membership at upload time.

### Attack surface rows

| Surface | Threat | Grade | Notes |
|---|---|---|---|
| `share_session` egress to Slack | Recorded session data (masked DOM + console/network) leaves the local store and enters Slack's cloud | `accepted` (explicit consent required) | Gated by an elicitation consent card before any file is written. Temp file deleted after upload. Audit log records the export. Residual: Slack retention applies post-upload; masking completeness depends on capture-time config. |
| Temp `.peekbundle` file on disk | Brief window between bundle write and upload deletion; a local process could read the file | `accepted` (local-peer-trust class) | Temp file has a random-nonce suffix. Sits in OS temp dir with normal umask. Deleted on success and on failure (best-effort). The window is bounded by the upload round-trip latency. |

## Cross-references

- [`docs/peek/PRIVACY_POLICY.md`](PRIVACY_POLICY.md)
Expand Down
271 changes: 271 additions & 0 deletions packages/connector-core/src/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type Anthropic from '@anthropic-ai/sdk';
import { describe, expect, it, vi } from 'vitest';
import type { AgentOutcome, Brain, Session } from './brain.js';
import type { PeekMcp } from './mcp.js';
import { ConnectorRuntime, classifyError } from './runtime.js';
import { SdkBrain } from './sdk-brain.js';
import type { SecretStore } from './secret-store.js';
import { SessionStore } from './store.js';
import type { ConsentResponse, InboundMessage, SurfaceAdapter } from './surface.js';
Expand Down Expand Up @@ -748,3 +753,269 @@ describe('ConnectorRuntime start() loads existing secret', () => {
expect(fakeMcp.setConnectorSecret).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// share_session interception tests (Task 3)
// ---------------------------------------------------------------------------

/** Extend FakeAdapter with postFile tracking. */
class FileAdapter extends FakeAdapter {
postFileCalls: Array<{
conversationId: string;
filePath: string;
filename: string;
comment: string;
}> = [];
async postFile(conversationId: string, filePath: string, filename: string, comment?: string) {
this.postFileCalls.push({ conversationId, filePath, filename, comment: comment ?? '' });
}
}

function anthropicMsg(
content: Anthropic.ContentBlock[],
stop: Anthropic.Message['stop_reason'],
): Anthropic.Message {
return {
id: 'm',
type: 'message',
role: 'assistant',
model: 'x',
content,
stop_reason: stop,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
} as Anthropic.Message;
}

/** A createMessage mock that first emits a `share_session` tool_use (a READ tool,
* so SdkBrain runs it INLINE via its injected callTool — never as a consent
* outcome), then a plain text end_turn once the tool result is fed back. */
function shareSessionCreateMessage(): (
req: Anthropic.MessageCreateParamsNonStreaming,
) => Promise<Anthropic.Message> {
return vi
.fn()
.mockResolvedValueOnce(
anthropicMsg(
[
{
type: 'tool_use',
id: 'tu-share-1',
name: 'share_session',
input: { sessionId: 's1', surface: 'slack' },
caller: { type: 'direct' },
} as Anthropic.ContentBlock,
],
'tool_use',
),
)
.mockResolvedValueOnce(
anthropicMsg([{ type: 'text', text: 'shared', citations: [] }], 'end_turn'),
);
}

/**
* Build a runtime wired to a REAL SdkBrain whose injected callTool routes through
* `runtime.interceptCallTool(...)` — exactly as the connector-slack composition
* root does. `innerCallTool` is the mocked low-level MCP callTool. This is the
* production path: share_session is read-classified, so SdkBrain calls it inline
* and the result flows through interceptCallTool, NOT handleConsentResponse.
*
* This mirroring is the whole point of the Critical fix — a test that drove a
* synthetic {kind:'consent'} brain (as commit 09219fc's tests did) would exercise
* the WRONG path and pass even though the interception never fires in production.
*/
function makeRealBrainRuntime(deps: {
adapter: SurfaceAdapter;
innerCallTool: (name: string, input: unknown) => Promise<string>;
}): { runtime: ConnectorRuntime } {
// Forward reference — mirrors the connector-slack composition root's construction-time
// dependency cycle (the brain's callTool closes over the runtime).
// biome-ignore lint/style/useConst: forward reference for a construction-time dependency cycle
let runtimeRef: ConnectorRuntime;
const brain = new SdkBrain({
createMessage: shareSessionCreateMessage(),
callTool: (name, input) =>
runtimeRef.interceptCallTool(name, input, (n, i) => deps.innerCallTool(n, i)),
tools: [],
model: 'm',
extendedReasoning: false,
});
const store = new SessionStore(() => brain.newSession());
// The runtime only touches mcp.callTool on the consent path; on the read path
// it is never called. A stub keeps the type happy.
const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp;
const runtime = new ConnectorRuntime({ adapter: deps.adapter, brain, mcp, store });
runtimeRef = runtime;
return { runtime };
}

describe('share_session interception — REAL SdkBrain inline (read) routing', () => {
it('MANDATORY: an approved share_session from the real SdkBrain triggers postFile + temp-file delete', async () => {
// Real temp file so deletion is observable.
const dir = await mkdtemp(join(tmpdir(), 'peek-test-'));
const bundlePath = join(dir, 'session.peekbundle');
await writeFile(bundlePath, 'bundle-data');

const toolResult = JSON.stringify({
ok: true,
bundlePath,
filename: 'session.peekbundle',
sizeBytes: 11,
caveat: 'contains data',
});
// Low-level MCP callTool the brain drives INLINE (share_session is read).
const innerCallTool = vi.fn().mockResolvedValue(toolResult);

const adapter = new FileAdapter();
const { runtime } = makeRealBrainRuntime({ adapter, innerCallTool });
await runtime.start();

// Drive a real turn end-to-end: message -> SdkBrain.runTurn -> inline
// callTool(share_session) -> interceptCallTool -> postFile + delete.
adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'share my session' });
await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'shared']));

// The brain ran share_session inline (never a consent outcome).
expect(innerCallTool).toHaveBeenCalledWith('share_session', {
sessionId: 's1',
surface: 'slack',
});
// No consent card was ever posted (proves the read/inline path, not consent).
expect(adapter.consents).toHaveLength(0);

// postFile fired with the active conversationId + bundle args.
expect(adapter.postFileCalls).toHaveLength(1);
expect(adapter.postFileCalls[0]?.conversationId).toBe('c1');
expect(adapter.postFileCalls[0]?.filePath).toBe(bundlePath);
expect(adapter.postFileCalls[0]?.filename).toBe('session.peekbundle');

// Temp file deleted.
await expect(rm(bundlePath, { force: false })).rejects.toThrow();
await rm(dir, { recursive: true, force: true });
});

it('deletes the temp file even when upload (postFile) throws — try/finally guarantee', async () => {
const dir = await mkdtemp(join(tmpdir(), 'peek-test-'));
const bundlePath = join(dir, 'session.peekbundle');
await writeFile(bundlePath, 'bundle-data');

const toolResult = JSON.stringify({
ok: true,
bundlePath,
filename: 'session.peekbundle',
sizeBytes: 11,
caveat: '',
});
const innerCallTool = vi.fn().mockResolvedValue(toolResult);

class ThrowingFileAdapter extends FakeAdapter {
async postFile(_c: string, _fp: string, _fn: string, _comment?: string): Promise<void> {
throw new Error('Slack upload failed');
}
}

const adapter = new ThrowingFileAdapter();
const { runtime } = makeRealBrainRuntime({ adapter, innerCallTool });
await runtime.start();

adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'share' });
// The upload error is surfaced to the brain as the tool result; the brain's
// second createMessage still returns end_turn 'shared', so the turn completes.
await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'shared']));

// Temp file deleted despite the upload failure.
await expect(rm(bundlePath, { force: false })).rejects.toThrow();
await rm(dir, { recursive: true, force: true });
});

it('does not call postFile and does not throw when result is { ok: false }', async () => {
const toolResult = JSON.stringify({ ok: false, result: 'denied', reason: 'user denied' });
const innerCallTool = vi.fn().mockResolvedValue(toolResult);
const adapter = new FileAdapter();
const { runtime } = makeRealBrainRuntime({ adapter, innerCallTool });
await runtime.start();

adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'share' });
await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'shared']));

expect(adapter.postFileCalls).toHaveLength(0);
});

it('degrades to a filename-only text note (no raw temp path) when adapter.postFile is undefined, and still deletes the temp file', async () => {
const dir = await mkdtemp(join(tmpdir(), 'peek-test-'));
const bundlePath = join(dir, 'session.peekbundle');
await writeFile(bundlePath, 'bundle-data');

const toolResult = JSON.stringify({
ok: true,
bundlePath,
filename: 'session.peekbundle',
sizeBytes: 11,
caveat: '',
});

// Capture the tool-result text fed back to the brain so we can assert the
// degrade note references the filename only and never the raw OS temp path.
let feedbackText = '';
const innerCallTool = vi.fn().mockResolvedValue(toolResult);

// FakeAdapter has no postFile. The second createMessage captures the
// tool_result the interception fed back to the brain, so we can assert the
// degrade note references the filename only (never the raw OS temp path).
// biome-ignore lint/style/useConst: forward reference (dependency cycle — see makeRealBrainRuntime).
let runtimeRef: ConnectorRuntime;
const createMessage = vi
.fn()
.mockResolvedValueOnce(
anthropicMsg(
[
{
type: 'tool_use',
id: 'tu-share-1',
name: 'share_session',
input: { sessionId: 's1', surface: 'slack' },
caller: { type: 'direct' },
} as Anthropic.ContentBlock,
],
'tool_use',
),
)
.mockImplementationOnce((req: Anthropic.MessageCreateParamsNonStreaming) => {
// The last user message carries the tool_result the interception produced.
const last = req.messages.at(-1) as { content: Anthropic.ToolResultBlockParam[] };
const block = last.content.find((b) => b.tool_use_id === 'tu-share-1');
feedbackText = typeof block?.content === 'string' ? block.content : '';
return Promise.resolve(
anthropicMsg([{ type: 'text', text: 'shared', citations: [] }], 'end_turn'),
);
});

const adapter = new FakeAdapter(); // no postFile
const brain = new SdkBrain({
createMessage,
callTool: (name, input) =>
runtimeRef.interceptCallTool(name, input, (n, i) => innerCallTool(n, i)),
tools: [],
model: 'm',
extendedReasoning: false,
});
const store = new SessionStore(() => brain.newSession());
const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp;
const runtime = new ConnectorRuntime({ adapter, brain, mcp, store });
runtimeRef = runtime;
await runtime.start();

adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'share' });
await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'shared']));

// The degrade note references the filename, never the raw OS temp path.
expect(feedbackText).toContain('session.peekbundle');
expect(feedbackText).not.toContain(bundlePath);
expect(feedbackText).not.toContain(dir);

// Temp file still deleted.
await expect(rm(bundlePath, { force: false })).rejects.toThrow();
await rm(dir, { recursive: true, force: true });
});
});
Loading
Loading