From 1ba89ff786ae08ba3452e032710e3de28812c883 Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Fri, 24 Jul 2026 16:06:56 -0400 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20split=20Phase=202=20Slice=205=20int?= =?UTF-8?q?o=20subslices=205a=E2=80=935f?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break external integrations into approval/OAuth foundations, Drive and YouTube workstreams, and pin Time Huddle until API availability is confirmed. Co-authored-by: Cursor --- docs/plans/phase-2.md | 197 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 174 insertions(+), 23 deletions(-) diff --git a/docs/plans/phase-2.md b/docs/plans/phase-2.md index 510ea06..7da058e 100644 --- a/docs/plans/phase-2.md +++ b/docs/plans/phase-2.md @@ -323,46 +323,197 @@ flowchart TD ## Slice 5: External Integrations -**Branch:** `phase2/external-integrations` +**Status:** Not started — branch from `development` (Slices 1–4 merged). Split into subslices below. + +**Umbrella branch:** `phase2/external-integrations` (or merge subslices into it for the final PR) **PR target:** `development` -**Depends on:** Slice 2 (phase2/mcp-consume) for MCP-based integration patterns +**Depends on:** Slice 2 (`phase2/mcp-consume`, merged) — tool factory / `ToolContext` patterns. Integrations themselves are native OAuth tools, not MCP servers. + +**Goal:** Add Google Drive and YouTube as tools with egress approval flow. Shared OAuth + encrypted token storage. Time Huddle pinned until API availability is confirmed. + +**Pinned — Time Huddle:** Do **not** implement `read_timehuddle` / `post_timehuddle` until we confirm Time Huddle (vs TimeHarbor) API availability, auth model, and test accounts. Track as subslice **5e** below; un-pin when API is confirmed. + +```mermaid +flowchart TD + S5a[5a Approval + egress ask] --> S5c[5c Google Drive] + S5b[5b OAuth + token store] --> S5c + S5b --> S5d[5d YouTube] + S5a --> S5d + S5b -.->|pinned| S5e[5e Time Huddle] + S5a -.->|pinned| S5e + S5c --> S5f[5f Wire + verify] + S5d --> S5f + S5e -.->|when unpinned| S5f +``` + +### Subslice 5a: Approval & `ask` egress + +**Branch:** `phase2/integrations-approval` + +**Depends on:** None (Slice 2 already on `development`) + +**Goal:** Make per-tool `egress: "ask"` real at call time. + +**Tasks:** + +- Wrapper before outbound tool execute: `suspendForApproval()` → session parks → user confirms → resume and run tool +- Align profile disposition keys with tool names (`read_drive` / `post_youtube` / … vs legacy `drive` / `youtube`) +- Surface `waiting_for_approval` clearly in CLI/host responses +- Unit tests with a mock “ask” tool + +**Acceptance:** Dummy outbound tool suspends → user confirms → tool runs → session resumes. + +**PR checklist:** + +- [ ] Call-time `ask` wrapper implemented +- [ ] Profile tool disposition keys aligned with tool names +- [ ] `waiting_for_approval` surfaced to user +- [ ] Mock ask-tool tests pass + +--- + +### Subslice 5b: Shared OAuth + encrypted tokens + +**Branch:** `phase2/integrations-oauth` -**Goal:** Add Google Drive, YouTube, and Time Huddle as tools with egress approval flow. +**Depends on:** None (can parallel 5a) + +**Goal:** Shared auth plumbing for Google (and later Time Huddle if OAuth). + +**Files:** + +- `packages/tools/src/integrations/oauth.ts` (new) + +**Tasks:** + +- OAuth helper (authorize, refresh, load/store) +- Encrypt-at-rest token store in session/profile; never log secrets +- Mock OAuth tests (no live Google required) + +**Acceptance:** Obtain → store → refresh → load token works in unit tests. + +**PR checklist:** + +- [ ] Shared OAuth helper module functional +- [ ] OAuth token storage encrypted at rest +- [ ] Mock OAuth tests pass + +--- + +### Subslice 5c: Google Drive + +**Branch:** `phase2/integrations-drive` + +**Depends on:** 5a + 5b + +**Goal:** `read_drive` (list/fetch files) via Google OAuth2. **Files:** - `packages/tools/src/integrations/drive.ts` (new) + +**Tasks:** + +- Implement `read_drive`; register in `createJerryTools()` with `ask` disposition +- Mock Drive API tests +- Manual: `jerry what files did I share today` + +**Acceptance:** Drive query runs after approval and returns results. + +**PR checklist:** + +- [ ] `read_drive` implemented with OAuth2 +- [ ] Wired into `createJerryTools()` with `ask` +- [ ] Mock API tests pass; manual scenario verified + +--- + +### Subslice 5d: YouTube + +**Branch:** `phase2/integrations-youtube` + +**Depends on:** 5a + 5b (can parallel 5c after those land) + +**Goal:** `post_youtube` (upload), `fetch_youtube` (metadata). + +**Files:** + - `packages/tools/src/integrations/youtube.ts` (new) + +**Tasks:** + +- Implement upload + metadata tools; `ask` by default +- Mock YouTube API tests +- Manual upload with approval + +**Acceptance:** `jerry upload this to youtube` → approval prompt → user confirms → video uploaded. + +**PR checklist:** + +- [ ] `post_youtube` and `fetch_youtube` implemented +- [ ] Wired into `createJerryTools()` with `ask` +- [ ] Mock API tests pass; manual upload verified + +--- + +### Subslice 5e: Time Huddle — **PINNED** + +**Branch:** `phase2/integrations-timehuddle` (when unpinned) + +**Depends on:** 5a + 5b, **plus** confirmed API availability / auth / test accounts + +**Status:** Pinned — confirm Time Huddle (vs TimeHarbor) API before starting. + +**Goal (when unpinned):** `read_timehuddle` (meetings), `post_timehuddle` (create note). + +**Files:** + - `packages/tools/src/integrations/timehuddle.ts` (new) -- `packages/tools/src/integrations/oauth.ts` (new: shared OAuth helper) + +**Tasks (when unpinned):** + +- Confirm product surface, API docs, and auth model +- Implement tools; reuse 5b patterns; `ask` by default +- Mock + documented manual test + +**Acceptance (when unpinned):** Read meetings and create note both work behind approval. + +**PR checklist:** + +- [ ] API availability and auth model confirmed (unblocks this subslice) +- [ ] `read_timehuddle` / `post_timehuddle` implemented +- [ ] Mock API tests pass; manual integration documented + +--- + +### Subslice 5f: Wire, docs, slice closeout + +**Branch:** merge into `phase2/external-integrations` (final PR) + +**Depends on:** 5c + 5d (and 5e only if unpinned) + +**Goal:** Slice 5 acceptance for Drive + YouTube; update plan status. **Tasks:** -- **Google Drive:** `read_drive` (list/fetch files), OAuth2 flow -- **YouTube:** `post_youtube` (upload video), `fetch_youtube` (get video metadata) -- **Time Huddle:** `read_timehuddle` (get meetings), `post_timehuddle` (create note) -- Wire into `createJerryTools()` with `egress: "ask"` disposition by default -- Implement `suspendForApproval()` flow — tool requests approval, session suspends, user grants, session resumes -- Store OAuth tokens in session/profile (encrypted at rest) -- Test: mock API responses; manual integration test with real accounts +- Default profile dispositions for shipped integration tools +- Document mock vs live manual integration tests +- Update this plan’s checklist / status +- Time Huddle remains out of scope until 5e is unpinned **Acceptance:** -- `jerry upload this to youtube` → approval prompt → user confirms → video uploaded -- `jerry what files did I share today` → Drive API queried → results returned +- `jerry upload this to youtube` → approval → upload +- `jerry what files did I share today` → Drive results **PR checklist:** -- [ ] Google Drive tools (`read_drive`) implemented with OAuth2 -- [ ] YouTube tools (`post_youtube`, `fetch_youtube`) implemented -- [ ] Time Huddle tools (`read_timehuddle`, `post_timehuddle`) implemented -- [ ] Shared OAuth helper module functional -- [ ] `suspendForApproval()` flow working end-to-end -- [ ] OAuth token storage encrypted -- [ ] Mock API tests pass; manual integration tests documented -- [ ] Acceptance scenarios verified manually +- [ ] Drive + YouTube acceptance scenarios verified manually +- [ ] Manual integration tests documented +- [ ] Phase-2 plan status updated +- [ ] Time Huddle still pinned (or 5e completed if unblocked) --- @@ -508,7 +659,7 @@ flowchart TD | 2. MCP Consume | `phase2/mcp-consume` | Slice 1 | 4 | Footnote MCP integration | | 3. MCP Expose | `phase2/mcp-expose` | Slice 1 | 3 | Jerry as MCP server | | 4. Runtime Backends | `phase2/runtime-backends` | None | 3 | byo-cloud + ozwell working | -| 5. Integrations | `phase2/external-integrations` | Slice 2 | 5 | Drive, YouTube, Time Huddle | +| 5. Integrations | `phase2/external-integrations` | Slice 2 | 5a–5f | Drive + YouTube + ask/OAuth; Time Huddle pinned | | 6. Webhooks and Digests | `phase2/webhooks-digests` | None | 4 | Scheduled digests working | | 7. Production Deploy | `phase2/production-deploy` | Slices 3-6 | 4 | Live on Cloudflare + mieweb/os | | 8. Optional Enhancements | `phase2/optional-enhancements` | Slice 7 | 3 | DuckDB, mobile stub | @@ -521,7 +672,7 @@ flowchart TD - Jerry consumes footnote MCP server for hybrid search - Jerry exposes tools as MCP server (Cursor integration documented) - All three runtime backends (`local`, `byo-cloud`, `ozwell`) functional -- Drive, YouTube, Time Huddle integrations with approval flow +- Drive and YouTube integrations with approval flow (Time Huddle pinned until API confirmed) - Webhooks trigger agent turns - Scheduled digests fire without external trigger - Production deploy on Cloudflare and mieweb/os From 7a05be26b749b244a42101cbadb0cc3ce05a15ae Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Fri, 24 Jul 2026 16:20:31 -0400 Subject: [PATCH 2/2] feat(tools): implement call-time ask egress approval flow (Phase 2 Slice 5a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename disposition keys: drive → read_drive, youtube → post_youtube/fetch_youtube - Add ToolEgress and ApprovalStore types to ToolContext - Implement wrapToolsWithAsk to wrap tool execute with approval checks - Add tool_approvals migration for pending/grant persistence - Wire approval store and resume grant flow in worker - Add unit tests for wrap-ask and approval-store modules - Document integration tools require egress: allow-tools in README Co-authored-by: Cursor --- packages/agent-runtime/README.md | 4 + packages/agent-runtime/src/egress.test.ts | 34 +-- packages/agent-runtime/src/profile.test.ts | 9 +- packages/agent-runtime/src/profile.ts | 5 +- .../migrations/0002_tool_approvals.sql | 17 ++ packages/jerry-app/src/approval-store.test.ts | 116 +++++++++ packages/jerry-app/src/approval-store.ts | 129 ++++++++++ packages/jerry-app/src/create-tools.ts | 60 ++++- packages/jerry-app/worker/index.mjs | 41 +++- packages/tools/src/runtime/index.ts | 16 +- packages/tools/src/runtime/types.ts | 26 ++ packages/tools/src/runtime/wrap-ask.test.ts | 227 ++++++++++++++++++ packages/tools/src/runtime/wrap-ask.ts | 100 ++++++++ 13 files changed, 755 insertions(+), 29 deletions(-) create mode 100644 packages/jerry-app/migrations/0002_tool_approvals.sql create mode 100644 packages/jerry-app/src/approval-store.test.ts create mode 100644 packages/jerry-app/src/approval-store.ts create mode 100644 packages/tools/src/runtime/wrap-ask.test.ts create mode 100644 packages/tools/src/runtime/wrap-ask.ts diff --git a/packages/agent-runtime/README.md b/packages/agent-runtime/README.md index 9f7320f..988877c 100644 --- a/packages/agent-runtime/README.md +++ b/packages/agent-runtime/README.md @@ -122,6 +122,10 @@ The default profile runs fully local with no network egress: For cloud runtimes (`byo-cloud` / `ozwell`), egress is automatically normalized to `allow-model` if left at `deny`. +### Integration tools (Drive, YouTube) + +Integration tools (`read_drive`, `post_youtube`, `fetch_youtube`) require `egress: "allow-tools"` to be available. Under `deny` or `allow-model`, these tools are filtered out at turn start. With `allow-tools`, they pass through but require call-time approval (`"ask"` disposition). + See [plan.md §4](../../plan.md) for the full trust and data-path control design. ## Environment Variables diff --git a/packages/agent-runtime/src/egress.test.ts b/packages/agent-runtime/src/egress.test.ts index 493d7b1..c2e7dad 100644 --- a/packages/agent-runtime/src/egress.test.ts +++ b/packages/agent-runtime/src/egress.test.ts @@ -51,35 +51,35 @@ describe("filterTools", () => { it("removes tools marked as ask", () => { const tools = { aw: makeTool("aw"), - drive: makeTool("drive"), + read_drive: makeTool("read_drive"), }; const profile: PrivacyProfile = { ...baseProfile, egress: "deny", - tools: { aw: "local", drive: "ask" }, + tools: { aw: "local", read_drive: "ask" }, }; const result = filterTools(tools, profile); assert.ok(result); assert.ok("aw" in result); - assert.ok(!("drive" in result)); + assert.ok(!("read_drive" in result)); }); it("removes tools marked as allow", () => { const tools = { aw: makeTool("aw"), - youtube: makeTool("youtube"), + post_youtube: makeTool("post_youtube"), }; const profile: PrivacyProfile = { ...baseProfile, egress: "deny", - tools: { aw: "local", youtube: "allow" }, + tools: { aw: "local", post_youtube: "allow" }, }; const result = filterTools(tools, profile); assert.ok(result); assert.ok("aw" in result); - assert.ok(!("youtube" in result)); + assert.ok(!("post_youtube" in result)); }); it("removes tools not in disposition list", () => { @@ -101,13 +101,13 @@ describe("filterTools", () => { it("returns undefined when all tools are filtered out", () => { const tools = { - drive: makeTool("drive"), - youtube: makeTool("youtube"), + read_drive: makeTool("read_drive"), + post_youtube: makeTool("post_youtube"), }; const profile: PrivacyProfile = { ...baseProfile, egress: "deny", - tools: { drive: "ask", youtube: "allow" }, + tools: { read_drive: "ask", post_youtube: "allow" }, }; const result = filterTools(tools, profile); @@ -119,18 +119,18 @@ describe("filterTools", () => { it("behaves same as deny (only local tools allowed)", () => { const tools = { aw: makeTool("aw"), - drive: makeTool("drive"), + read_drive: makeTool("read_drive"), }; const profile: PrivacyProfile = { ...baseProfile, egress: "allow-model", - tools: { aw: "local", drive: "ask" }, + tools: { aw: "local", read_drive: "ask" }, }; const result = filterTools(tools, profile); assert.ok(result); assert.ok("aw" in result); - assert.ok(!("drive" in result)); + assert.ok(!("read_drive" in result)); }); }); @@ -138,20 +138,20 @@ describe("filterTools", () => { it("passes through all tools", () => { const tools = { aw: makeTool("aw"), - drive: makeTool("drive"), - youtube: makeTool("youtube"), + read_drive: makeTool("read_drive"), + post_youtube: makeTool("post_youtube"), }; const profile: PrivacyProfile = { ...baseProfile, egress: "allow-tools", - tools: { aw: "local", drive: "ask", youtube: "allow" }, + tools: { aw: "local", read_drive: "ask", post_youtube: "allow" }, }; const result = filterTools(tools, profile); assert.ok(result); assert.ok("aw" in result); - assert.ok("drive" in result); - assert.ok("youtube" in result); + assert.ok("read_drive" in result); + assert.ok("post_youtube" in result); }); it("passes through tools not in disposition list", () => { diff --git a/packages/agent-runtime/src/profile.test.ts b/packages/agent-runtime/src/profile.test.ts index bbb40a4..79f2485 100644 --- a/packages/agent-runtime/src/profile.test.ts +++ b/packages/agent-runtime/src/profile.test.ts @@ -11,8 +11,8 @@ describe("DEFAULT_PRIVACY_PROFILE", () => { assert.equal(DEFAULT_PRIVACY_PROFILE.runtime, "local"); }); - it("uses ollama:qwen2.5 model", () => { - assert.equal(DEFAULT_PRIVACY_PROFILE.model, "ollama:qwen2.5"); + it("uses ollama:llama3.1:8b model", () => { + assert.equal(DEFAULT_PRIVACY_PROFILE.model, "ollama:llama3.1:8b"); }); it("denies egress by default", () => { @@ -31,8 +31,9 @@ describe("DEFAULT_PRIVACY_PROFILE", () => { search_fts: "local", search_literal: "local", read_document: "local", - drive: "ask", - youtube: "ask", + read_drive: "ask", + post_youtube: "ask", + fetch_youtube: "ask", }); }); }); diff --git a/packages/agent-runtime/src/profile.ts b/packages/agent-runtime/src/profile.ts index 10b727a..e5a0f45 100644 --- a/packages/agent-runtime/src/profile.ts +++ b/packages/agent-runtime/src/profile.ts @@ -45,8 +45,9 @@ export const DEFAULT_PRIVACY_PROFILE: PrivacyProfile = { search_fts: "local", search_literal: "local", read_document: "local", - drive: "ask", - youtube: "ask", + read_drive: "ask", + post_youtube: "ask", + fetch_youtube: "ask", }, }; diff --git a/packages/jerry-app/migrations/0002_tool_approvals.sql b/packages/jerry-app/migrations/0002_tool_approvals.sql new file mode 100644 index 0000000..1ec4024 --- /dev/null +++ b/packages/jerry-app/migrations/0002_tool_approvals.sql @@ -0,0 +1,17 @@ +-- Tool approvals: pending and granted tool calls requiring user approval +-- Used by the "ask" egress disposition flow. + +CREATE TABLE IF NOT EXISTS tool_approvals ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + args_json TEXT, -- JSON: tool arguments at time of request + status TEXT NOT NULL DEFAULT 'pending', -- pending, granted, expired + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) +); + +CREATE INDEX IF NOT EXISTS idx_tool_approvals_session ON tool_approvals(session_id); +CREATE INDEX IF NOT EXISTS idx_tool_approvals_status ON tool_approvals(status); +CREATE INDEX IF NOT EXISTS idx_tool_approvals_tool ON tool_approvals(tool_name); diff --git a/packages/jerry-app/src/approval-store.test.ts b/packages/jerry-app/src/approval-store.test.ts new file mode 100644 index 0000000..37b8cb0 --- /dev/null +++ b/packages/jerry-app/src/approval-store.test.ts @@ -0,0 +1,116 @@ +/** + * Unit tests for approval-store.ts — memory-backed approval store. + */ + +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createMemoryApprovalStore } from "./approval-store.js"; + +describe("createMemoryApprovalStore", () => { + let store: ReturnType; + + beforeEach(() => { + store = createMemoryApprovalStore(); + }); + + describe("hasGrant", () => { + it("returns false when no approval exists", async () => { + const result = await store.hasGrant("session-1", "tool_a"); + assert.equal(result, false); + }); + + it("returns false for pending approval", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + const result = await store.hasGrant("session-1", "tool_a"); + assert.equal(result, false); + }); + + it("returns true for granted approval", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + await store.grantPending("session-1", "tool_a"); + const result = await store.hasGrant("session-1", "tool_a"); + assert.equal(result, true); + }); + }); + + describe("createPending", () => { + it("creates a pending approval and returns an ID", async () => { + const id = await store.createPending("session-1", "tool_a", { arg: 1 }); + assert.ok(id); + assert.ok(typeof id === "string"); + }); + + it("stores the args with the pending approval", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + const hasPending = await store.hasGrant("session-1", "tool_a"); + assert.equal(hasPending, false); // pending, not granted + }); + }); + + describe("grantPending", () => { + it("returns true when granting existing pending approval", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + const result = await store.grantPending("session-1", "tool_a"); + assert.equal(result, true); + }); + + it("returns false when no pending approval exists", async () => { + const result = await store.grantPending("session-1", "nonexistent"); + assert.equal(result, false); + }); + + it("returns false when approval is already granted", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + await store.grantPending("session-1", "tool_a"); + const result = await store.grantPending("session-1", "tool_a"); + assert.equal(result, false); + }); + }); + + describe("consumeGrant", () => { + it("returns true and expires granted approval", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + await store.grantPending("session-1", "tool_a"); + + const result = await store.consumeGrant("session-1", "tool_a"); + assert.equal(result, true); + + // Grant is now expired + const hasGrant = await store.hasGrant("session-1", "tool_a"); + assert.equal(hasGrant, false); + }); + + it("returns false when no granted approval exists", async () => { + const result = await store.consumeGrant("session-1", "nonexistent"); + assert.equal(result, false); + }); + + it("returns false when approval is pending (not granted)", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + const result = await store.consumeGrant("session-1", "tool_a"); + assert.equal(result, false); + }); + }); + + describe("isolation between sessions", () => { + it("grants for different sessions are independent", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + await store.createPending("session-2", "tool_a", { arg: 2 }); + + await store.grantPending("session-1", "tool_a"); + + assert.equal(await store.hasGrant("session-1", "tool_a"), true); + assert.equal(await store.hasGrant("session-2", "tool_a"), false); + }); + + it("grants for different tools in same session are independent", async () => { + await store.createPending("session-1", "tool_a", { arg: 1 }); + await store.createPending("session-1", "tool_b", { arg: 2 }); + + await store.grantPending("session-1", "tool_a"); + + assert.equal(await store.hasGrant("session-1", "tool_a"), true); + assert.equal(await store.hasGrant("session-1", "tool_b"), false); + }); + }); +}); diff --git a/packages/jerry-app/src/approval-store.ts b/packages/jerry-app/src/approval-store.ts new file mode 100644 index 0000000..dd97fb7 --- /dev/null +++ b/packages/jerry-app/src/approval-store.ts @@ -0,0 +1,129 @@ +/** + * Database-backed approval store for tool call approvals. + * + * Persists pending tool approvals and grants to the tool_approvals table. + * Used by the ask-disposition flow to require human approval before + * executing tools that may reach out to external services. + */ + +import type { ApprovalStore, ToolContext } from "@mieweb/jerry-tools/runtime"; + +type CloudDatabase = ToolContext["db"]; + +/** + * Create a database-backed approval store. + * + * @param db - Cloud database binding + * @returns ApprovalStore implementation + */ +export function createDbApprovalStore(db: CloudDatabase): ApprovalStore { + return { + async hasGrant(sessionId: string, toolName: string): Promise { + const result = await db + .prepare( + `SELECT id FROM tool_approvals + WHERE session_id = ? AND tool_name = ? AND status = 'granted' + LIMIT 1` + ) + .bind(sessionId, toolName) + .first<{ id: string }>(); + + return result !== null; + }, + + async createPending( + sessionId: string, + toolName: string, + args: unknown + ): Promise { + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + const argsJson = JSON.stringify(args); + + await db + .prepare( + `INSERT INTO tool_approvals (id, session_id, tool_name, args_json, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?)` + ) + .bind(id, sessionId, toolName, argsJson, now, now) + .run(); + + return id; + }, + + async grantPending(sessionId: string, toolName: string): Promise { + const now = new Date().toISOString(); + + const result = await db + .prepare( + `UPDATE tool_approvals + SET status = 'granted', updated_at = ? + WHERE session_id = ? AND tool_name = ? AND status = 'pending'` + ) + .bind(now, sessionId, toolName) + .run(); + + return (result.meta?.changes ?? 0) > 0; + }, + + async consumeGrant(sessionId: string, toolName: string): Promise { + const now = new Date().toISOString(); + + const result = await db + .prepare( + `UPDATE tool_approvals + SET status = 'expired', updated_at = ? + WHERE session_id = ? AND tool_name = ? AND status = 'granted'` + ) + .bind(now, sessionId, toolName) + .run(); + + return (result.meta?.changes ?? 0) > 0; + }, + }; +} + +/** + * In-memory approval store for testing. + */ +export function createMemoryApprovalStore(): ApprovalStore { + const approvals = new Map(); + + const key = (sessionId: string, toolName: string) => + `${sessionId}:${toolName}`; + + return { + async hasGrant(sessionId: string, toolName: string): Promise { + const entry = approvals.get(key(sessionId, toolName)); + return entry?.status === "granted"; + }, + + async createPending( + sessionId: string, + toolName: string, + args: unknown + ): Promise { + const id = crypto.randomUUID(); + approvals.set(key(sessionId, toolName), { status: "pending", args }); + return id; + }, + + async grantPending(sessionId: string, toolName: string): Promise { + const entry = approvals.get(key(sessionId, toolName)); + if (entry?.status === "pending") { + entry.status = "granted"; + return true; + } + return false; + }, + + async consumeGrant(sessionId: string, toolName: string): Promise { + const entry = approvals.get(key(sessionId, toolName)); + if (entry?.status === "granted") { + entry.status = "expired"; + return true; + } + return false; + }, + }; +} diff --git a/packages/jerry-app/src/create-tools.ts b/packages/jerry-app/src/create-tools.ts index 8c63836..6d25343 100644 --- a/packages/jerry-app/src/create-tools.ts +++ b/packages/jerry-app/src/create-tools.ts @@ -13,12 +13,14 @@ import { mergeProfile, type PrivacyProfile } from "@mieweb/jerry-agent-runtime"; import { createJerryTools, type ToolContext, + type ToolEgress, } from "@mieweb/jerry-tools/runtime"; import { createFootnoteMcpTools, createMcpClient, } from "@mieweb/jerry-tools/mcp"; import { isMcpAvailable, resolveMcpServers } from "./mcp-config.js"; +import { createDbApprovalStore } from "./approval-store.js"; type JerryToolSet = ReturnType; @@ -28,6 +30,11 @@ let mcpLoadedForProfileKey: string | undefined; /** Monotonic generation so stale loads cannot overwrite a newer cache. */ let mcpLoadGeneration = 0; +/** Cached profile dispositions from the latest ensureMcpTools call. */ +let cachedDispositions: Record | undefined; +/** Cached egress policy from the latest ensureMcpTools call. */ +let cachedEgressPolicy: string | undefined; + function profileKey(profile?: PrivacyProfile): string { return JSON.stringify(profile?.mcp?.servers ?? "default"); } @@ -72,6 +79,7 @@ export async function loadMcpTools( /** * Ensure MCP tools are loaded for the given profile before a turn runs. + * Also caches profile dispositions for the ask-egress flow. * Safe to call from fetch and queue handlers; concurrent calls coalesce. */ export async function ensureMcpTools( @@ -80,6 +88,10 @@ export async function ensureMcpTools( const resolved = mergeProfile(profile ?? {}); const key = profileKey(resolved); + // Always update disposition cache from latest profile + cachedDispositions = resolved.tools as Record | undefined; + cachedEgressPolicy = resolved.egress; + if (mcpToolsCache && mcpLoadedForProfileKey === key) { return mcpToolsCache; } @@ -106,10 +118,24 @@ export async function ensureMcpTools( * When footnote hybrid search is loaded, search_memory is omitted so the * agent uses search_hybrid for document queries. * + * When egress is "allow-tools", tools with "ask" disposition are wrapped + * with approval flow. + * * Requires ensureMcpTools() to have completed for this process first. */ export function createJerryToolsWithMcp(ctx: ToolContext): JerryToolSet { - const tools = createJerryTools(ctx, { mcpTools: mcpToolsCache }); + // Enrich context with dispositions and approval store for ask-egress flow + const shouldWrapAsk = cachedEgressPolicy === "allow-tools" && !!cachedDispositions; + const enrichedCtx: ToolContext = { + ...ctx, + dispositions: cachedDispositions, + approvalStore: shouldWrapAsk ? createDbApprovalStore(ctx.db) : undefined, + }; + + const tools = createJerryTools(enrichedCtx, { + mcpTools: mcpToolsCache, + wrapAsk: shouldWrapAsk, + }); if (mcpToolsCache?.search_hybrid) { const result = { ...tools }; @@ -128,6 +154,38 @@ export function resetMcpToolsCache(): void { mcpLoadPromise = undefined; mcpLoadedForProfileKey = undefined; mcpLoadGeneration++; + cachedDispositions = undefined; + cachedEgressPolicy = undefined; +} + +/** + * Grant a pending tool approval (for resume flow). + * Called when a session resumes from waiting_for_approval status. + */ +export async function grantPendingApproval( + ctx: ToolContext, + toolName: string +): Promise { + const store = createDbApprovalStore(ctx.db); + return store.grantPending(ctx.sessionId, toolName); +} + +/** + * Get the pending tool name for a session (for resume flow). + */ +export async function getPendingToolName( + ctx: ToolContext +): Promise { + const result = await ctx.db + .prepare( + `SELECT tool_name FROM tool_approvals + WHERE session_id = ? AND status = 'pending' + ORDER BY created_at DESC LIMIT 1` + ) + .bind(ctx.sessionId) + .first<{ tool_name: string }>(); + + return result?.tool_name ?? null; } /** diff --git a/packages/jerry-app/worker/index.mjs b/packages/jerry-app/worker/index.mjs index 87d0872..f5370ac 100644 --- a/packages/jerry-app/worker/index.mjs +++ b/packages/jerry-app/worker/index.mjs @@ -16,12 +16,19 @@ import { jerry } from "../src/agent.ts"; import { createJerryToolsWithMcp, ensureMcpTools, + grantPendingApproval, + getPendingToolName, } from "../src/create-tools.ts"; /** * Preload MCP tools before agent turns that need external search. + * Also handles granting pending approvals on resume from waiting_for_approval. + * + * @param {Request} request + * @param {URL} url + * @param {Env} env */ -async function prepareMcpForRequest(request, url) { +async function prepareMcpForRequest(request, url, env) { if (request.method !== "POST") return; if ( !url.pathname.includes("/v1/sessions/") || @@ -30,9 +37,39 @@ async function prepareMcpForRequest(request, url) { return; } + // Extract session ID from URL: /v1/sessions/:id/messages + const sessionMatch = url.pathname.match(/\/v1\/sessions\/([^/]+)\//); + const sessionId = sessionMatch?.[1]; + try { const body = await request.clone().json(); await ensureMcpTools(body.profile); + + // Check if this is a resume from waiting_for_approval and grant the pending tool + if (sessionId && env.DB) { + const session = await env.DB + .prepare("SELECT status FROM sessions WHERE id = ?") + .bind(sessionId) + .first(); + + if (session?.status === "waiting_for_approval") { + // Create minimal context for approval operations + const ctx = { + sessionId, + db: env.DB, + vectors: env.VECTORS, + bucket: env.BUCKET, + scheduleWake: async () => {}, + suspendForUser: () => {}, + suspendForApproval: () => {}, + }; + + const pendingTool = await getPendingToolName(ctx); + if (pendingTool) { + await grantPendingApproval(ctx, pendingTool); + } + } + } } catch { await ensureMcpTools(); } @@ -191,7 +228,7 @@ export default { // Delegate to hostAgent for all agent routes // Routes: /v1/sessions/:id/messages, /v1/sessions/:id/enqueue, /v1/sessions/:id/status, /v1/events try { - await prepareMcpForRequest(request, url); + await prepareMcpForRequest(request, url, env); return await host.handleFetch(request, env); } catch (err) { console.error("Fetch error:", err); diff --git a/packages/tools/src/runtime/index.ts b/packages/tools/src/runtime/index.ts index 6d4e3c0..3506a35 100644 --- a/packages/tools/src/runtime/index.ts +++ b/packages/tools/src/runtime/index.ts @@ -12,18 +12,22 @@ import { createSearchMemoryTool } from "./search-memory.js"; import { createScheduleFollowupTool } from "./schedule-followup.js"; import { createReadFileTool, createListWatchedTool } from "./file-tools.js"; import { createIndexDocumentTool } from "./index-document.js"; +import { wrapToolsWithAsk } from "./wrap-ask.js"; -export type { ToolContext, StoredActivityEvent } from "./types.js"; +export type { ToolContext, StoredActivityEvent, ToolEgress, ApprovalStore } from "./types.js"; export { createSummarizeActivityTool } from "./summarize-activity.js"; export { createSearchMemoryTool } from "./search-memory.js"; export { createScheduleFollowupTool } from "./schedule-followup.js"; export { createReadFileTool, createListWatchedTool } from "./file-tools.js"; export { createIndexDocumentTool } from "./index-document.js"; export { getEmbedding, isOllamaAvailable } from "./embeddings.js"; +export { wrapToolWithAsk, wrapToolsWithAsk, type WaitingForApprovalResult } from "./wrap-ask.js"; export interface CreateJerryToolsOptions { /** Optional MCP tools to merge (e.g. from footnote adapter) */ mcpTools?: ToolSet; + /** Whether to wrap tools with ask-disposition approval flow */ + wrapAsk?: boolean; } /** @@ -47,9 +51,15 @@ export function createJerryTools( }; // Merge MCP tools if provided (they take precedence for overlapping names) + let tools: ToolSet = coreTools; if (options?.mcpTools) { - return { ...coreTools, ...options.mcpTools }; + tools = { ...coreTools, ...options.mcpTools }; } - return coreTools; + // Wrap tools with ask-disposition approval flow if enabled + if (options?.wrapAsk && ctx.dispositions && ctx.approvalStore) { + tools = wrapToolsWithAsk(tools, ctx); + } + + return tools; } diff --git a/packages/tools/src/runtime/types.ts b/packages/tools/src/runtime/types.ts index 34a3ec8..2a7a134 100644 --- a/packages/tools/src/runtime/types.ts +++ b/packages/tools/src/runtime/types.ts @@ -4,6 +4,28 @@ import type { CloudDatabase, CloudVectorIndex, CloudBucket } from "@mieweb/cloud-types"; +/** + * Per-tool egress disposition (mirrors agent-runtime types). + * - `local`: Tool runs locally, no network access. + * - `ask`: Tool may reach out, but requires human-in-the-loop approval first. + * - `allow`: Tool may reach out without approval. + */ +export type ToolEgress = "local" | "ask" | "allow"; + +/** + * Approval store interface for persisting pending tool approvals. + */ +export interface ApprovalStore { + /** Check if a tool has a valid grant for this session */ + hasGrant(sessionId: string, toolName: string): Promise; + /** Create a pending approval request */ + createPending(sessionId: string, toolName: string, args: unknown): Promise; + /** Grant approval (mark pending as granted) */ + grantPending(sessionId: string, toolName: string): Promise; + /** Consume a grant (use once then clear) */ + consumeGrant(sessionId: string, toolName: string): Promise; +} + /** * Context passed to tools during execution. * Contains bindings and control functions. @@ -23,6 +45,10 @@ export interface ToolContext { suspendForUser: (message: string) => void; /** Suspend waiting for approval */ suspendForApproval: (message: string) => void; + /** Per-tool egress dispositions from profile (optional) */ + dispositions?: Record; + /** Approval store for ask-disposition tools (optional) */ + approvalStore?: ApprovalStore; } /** diff --git a/packages/tools/src/runtime/wrap-ask.test.ts b/packages/tools/src/runtime/wrap-ask.test.ts new file mode 100644 index 0000000..2cdb730 --- /dev/null +++ b/packages/tools/src/runtime/wrap-ask.test.ts @@ -0,0 +1,227 @@ +/** + * Unit tests for wrap-ask.ts — ask-disposition approval flow. + */ + +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; +import { tool } from "ai"; +import { wrapToolWithAsk, wrapToolsWithAsk } from "./wrap-ask.js"; +import type { ToolContext, ApprovalStore, ToolEgress } from "./types.js"; + +const makeTool = (name: string, execute: (args: unknown) => Promise) => + tool({ + description: `Test tool: ${name}`, + parameters: z.object({ input: z.string().optional() }), + execute, + }); + +function createMemoryApprovalStore(): ApprovalStore & { + approvals: Map; +} { + const approvals = new Map(); + const key = (sessionId: string, toolName: string) => + `${sessionId}:${toolName}`; + + return { + approvals, + async hasGrant(sessionId: string, toolName: string): Promise { + const entry = approvals.get(key(sessionId, toolName)); + return entry?.status === "granted"; + }, + async createPending( + sessionId: string, + toolName: string, + args: unknown + ): Promise { + const id = crypto.randomUUID(); + approvals.set(key(sessionId, toolName), { status: "pending", args }); + return id; + }, + async grantPending(sessionId: string, toolName: string): Promise { + const entry = approvals.get(key(sessionId, toolName)); + if (entry?.status === "pending") { + entry.status = "granted"; + return true; + } + return false; + }, + async consumeGrant(sessionId: string, toolName: string): Promise { + const entry = approvals.get(key(sessionId, toolName)); + if (entry?.status === "granted") { + entry.status = "expired"; + return true; + } + return false; + }, + }; +} + +function createMockContext( + dispositions?: Record, + approvalStore?: ApprovalStore +): ToolContext { + let suspendMessage: string | null = null; + + return { + sessionId: "test-session", + db: {} as ToolContext["db"], + scheduleWake: async () => {}, + suspendForUser: () => {}, + suspendForApproval: (message: string) => { + suspendMessage = message; + }, + dispositions, + approvalStore, + get _suspendMessage() { + return suspendMessage; + }, + } as ToolContext & { _suspendMessage: string | null }; +} + +describe("wrapToolWithAsk", () => { + let executeCalled = false; + let store: ReturnType; + + beforeEach(() => { + executeCalled = false; + store = createMemoryApprovalStore(); + }); + + it("does not wrap tools with 'local' disposition", async () => { + const originalTool = makeTool("local_tool", async () => { + executeCalled = true; + return "result"; + }); + const ctx = createMockContext({ local_tool: "local" }, store); + + const wrapped = wrapToolWithAsk("local_tool", originalTool, ctx, "local"); + const result = await wrapped.execute?.({ input: "test" }, {} as never); + + assert.equal(executeCalled, true); + assert.equal(result, "result"); + }); + + it("does not wrap tools with 'allow' disposition", async () => { + const originalTool = makeTool("allow_tool", async () => { + executeCalled = true; + return "result"; + }); + const ctx = createMockContext({ allow_tool: "allow" }, store); + + const wrapped = wrapToolWithAsk("allow_tool", originalTool, ctx, "allow"); + const result = await wrapped.execute?.({ input: "test" }, {} as never); + + assert.equal(executeCalled, true); + assert.equal(result, "result"); + }); + + it("suspends execution for 'ask' disposition without grant", async () => { + const originalTool = makeTool("ask_tool", async () => { + executeCalled = true; + return "result"; + }); + const ctx = createMockContext({ ask_tool: "ask" }, store); + + const wrapped = wrapToolWithAsk("ask_tool", originalTool, ctx, "ask"); + const result = (await wrapped.execute?.( + { input: "test" }, + {} as never + )) as { status: string }; + + assert.equal(executeCalled, false); + assert.equal(result?.status, "waiting_for_approval"); + assert.ok(store.approvals.has("test-session:ask_tool")); + assert.equal(store.approvals.get("test-session:ask_tool")?.status, "pending"); + }); + + it("executes tool after grant is consumed", async () => { + const originalTool = makeTool("ask_tool", async () => { + executeCalled = true; + return "approved result"; + }); + const ctx = createMockContext({ ask_tool: "ask" }, store); + + // Pre-grant the tool + await store.createPending("test-session", "ask_tool", {}); + await store.grantPending("test-session", "ask_tool"); + + const wrapped = wrapToolWithAsk("ask_tool", originalTool, ctx, "ask"); + const result = await wrapped.execute?.({ input: "test" }, {} as never); + + assert.equal(executeCalled, true); + assert.equal(result, "approved result"); + assert.equal(store.approvals.get("test-session:ask_tool")?.status, "expired"); + }); + + it("returns original tool if no approval store provided", async () => { + const originalTool = makeTool("ask_tool", async () => { + executeCalled = true; + return "no store result"; + }); + const ctx = createMockContext({ ask_tool: "ask" }, undefined); + + const wrapped = wrapToolWithAsk("ask_tool", originalTool, ctx, "ask"); + const result = await wrapped.execute?.({ input: "test" }, {} as never); + + assert.equal(executeCalled, true); + assert.equal(result, "no store result"); + }); +}); + +describe("wrapToolsWithAsk", () => { + it("wraps multiple tools based on dispositions", async () => { + const store = createMemoryApprovalStore(); + let localExecuted = false; + let askExecuted = false; + + const tools = { + local_tool: makeTool("local_tool", async () => { + localExecuted = true; + return "local"; + }), + ask_tool: makeTool("ask_tool", async () => { + askExecuted = true; + return "ask"; + }), + }; + + const ctx = createMockContext( + { local_tool: "local", ask_tool: "ask" }, + store + ); + + const wrapped = wrapToolsWithAsk(tools, ctx); + + // Local tool executes immediately + await wrapped.local_tool.execute?.({}, {} as never); + assert.equal(localExecuted, true); + + // Ask tool suspends + const askResult = (await wrapped.ask_tool.execute?.( + {}, + {} as never + )) as { status: string }; + assert.equal(askExecuted, false); + assert.equal(askResult?.status, "waiting_for_approval"); + }); + + it("handles tools without dispositions in context", async () => { + const store = createMemoryApprovalStore(); + let executed = false; + + const tools = { + unknown_tool: makeTool("unknown_tool", async () => { + executed = true; + return "unknown"; + }), + }; + + const ctx = createMockContext(undefined, store); + + const wrapped = wrapToolsWithAsk(tools, ctx); + await wrapped.unknown_tool.execute?.({}, {} as never); + + assert.equal(executed, true); + }); +}); diff --git a/packages/tools/src/runtime/wrap-ask.ts b/packages/tools/src/runtime/wrap-ask.ts new file mode 100644 index 0000000..791011b --- /dev/null +++ b/packages/tools/src/runtime/wrap-ask.ts @@ -0,0 +1,100 @@ +/** + * Wrap tools with ask-disposition approval flow. + * + * Under egress: "allow-tools", tools with "ask" disposition require + * human-in-the-loop approval before execution. This module wraps each + * tool's execute function to check approval state and suspend if needed. + */ + +import type { Tool, ToolSet } from "ai"; +import type { ToolContext, ToolEgress } from "./types.js"; + +/** + * Result returned when a tool is waiting for approval. + */ +export interface WaitingForApprovalResult { + status: "waiting_for_approval"; + toolName: string; + message: string; +} + +/** + * Wrap a single tool with ask-disposition approval flow. + * + * @param name - Tool name + * @param tool - Original tool + * @param ctx - Tool context with approval store and suspend hooks + * @param disposition - Tool's egress disposition + * @returns Wrapped tool (or original if no wrapping needed) + */ +export function wrapToolWithAsk( + name: string, + tool: T, + ctx: ToolContext, + disposition: ToolEgress | undefined +): T { + if (disposition !== "ask") { + return tool; + } + + if (!ctx.approvalStore) { + return tool; + } + + const originalExecute = tool.execute; + if (!originalExecute) { + return tool; + } + + const wrappedExecute = async (args: unknown, options: unknown) => { + const { sessionId, approvalStore, suspendForApproval } = ctx; + + const hasGrant = await approvalStore!.hasGrant(sessionId, name); + + if (hasGrant) { + const consumed = await approvalStore!.consumeGrant(sessionId, name); + if (consumed) { + return originalExecute(args, options as Parameters[1]); + } + } + + const argsPreview = JSON.stringify(args, null, 2).slice(0, 200); + const message = `Tool "${name}" requires approval.\n\nArguments:\n${argsPreview}\n\nReply to approve and continue.`; + + await approvalStore!.createPending(sessionId, name, args); + suspendForApproval(message); + + return { + status: "waiting_for_approval", + toolName: name, + message: `Waiting for approval to execute ${name}`, + } satisfies WaitingForApprovalResult; + }; + + return { + ...tool, + execute: wrappedExecute, + } as T; +} + +/** + * Wrap all tools in a ToolSet with ask-disposition approval flow. + * + * @param tools - Original ToolSet + * @param ctx - Tool context with approval store and suspend hooks + * @returns Wrapped ToolSet + */ +export function wrapToolsWithAsk( + tools: ToolSet, + ctx: ToolContext +): ToolSet { + const dispositions = ctx.dispositions ?? {}; + + const wrapped: ToolSet = {}; + for (const [name, tool] of Object.entries(tools)) { + const disposition = dispositions[name]; + wrapped[name] = wrapToolWithAsk(name, tool, ctx, disposition); + } + + return wrapped; +}