Skip to content

Commit 5179c0a

Browse files
serge-the-devclaude
andcommitted
feat(connectors): write-consent gate + first write tool (github_create_issue) (#90)
Writes operate with the user's real credentials, so they're gated behind explicit per-instance consent — the prerequisite for every write connector (Sheets next, #89). - migration 0051: instance_connector_consent (instance_id, connector, scope=write). - lib/connector-consent.ts: hasConsent / setConsent / revokeConsent / listConsents / listAllConsents (admin). Fail-closed. - runRegistryTool: a scope:"write" tool is refused unless the instance has write consent for its connector (and there IS an instance context) — enforced uniformly for the runtime, the API, and MCP. - First write tool: github_create_issue (scope:"write"), reusing the GitHub-App token. - Owner routes (routes/tools.ts): GET /:id/connectors/consent, PUT /:id/connectors/:connector/consent { enabled }. - Admin visibility: GET /v1/admin/connectors (all consents across users). - Tests: consent fail-closed (no consent / no instance) + gate-passes-when-granted; full API suite green (717). Part of epic #84. Next: Google Sheets connector (#89) rides this gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3049d33 commit 5179c0a

7 files changed

Lines changed: 172 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Write-consent for connector tools (issue #90). Reads via a connected system are
2+
-- allowed once the connector is connected; WRITES (create issue/PR, append to a
3+
-- sheet, …) operate with the user's real credentials, so they require explicit
4+
-- per-instance consent for that connector's write scope. A row here = "this instance
5+
-- may use <connector> tools of <scope>." Enforced in runRegistryTool (fail-closed:
6+
-- no row → no write).
7+
CREATE TABLE IF NOT EXISTS instance_connector_consent (
8+
instance_id TEXT NOT NULL REFERENCES agent_instances(id),
9+
user_id TEXT NOT NULL REFERENCES users(id),
10+
connector TEXT NOT NULL, -- e.g. 'github'
11+
scope TEXT NOT NULL, -- 'write' (reads don't need consent)
12+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
13+
PRIMARY KEY (instance_id, connector, scope)
14+
);
15+
CREATE INDEX IF NOT EXISTS idx_connector_consent_user ON instance_connector_consent(user_id, created_at DESC);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Per-instance write-consent for connector tools (issue #90). Reads are allowed
2+
// once a connector is connected; writes require an explicit consent row. All queries
3+
// parameterized. See migration 0051.
4+
import type { Env } from "../types.js";
5+
6+
export type ConnectorScope = "write";
7+
8+
/** Does this instance have consent for <connector> <scope>? Fail-closed on any error. */
9+
export async function hasConsent(
10+
env: Env,
11+
instanceId: string | undefined,
12+
connector: string,
13+
scope: ConnectorScope,
14+
): Promise<boolean> {
15+
if (!instanceId) return false;
16+
try {
17+
const row = await env.DB.prepare(
18+
"SELECT 1 AS ok FROM instance_connector_consent WHERE instance_id = ?1 AND connector = ?2 AND scope = ?3",
19+
).bind(instanceId, connector, scope).first<{ ok: number }>();
20+
return !!row;
21+
} catch {
22+
return false;
23+
}
24+
}
25+
26+
export async function setConsent(env: Env, instanceId: string, userId: string, connector: string, scope: ConnectorScope): Promise<void> {
27+
await env.DB.prepare(
28+
`INSERT INTO instance_connector_consent (instance_id, user_id, connector, scope)
29+
VALUES (?1, ?2, ?3, ?4)
30+
ON CONFLICT(instance_id, connector, scope) DO NOTHING`,
31+
).bind(instanceId, userId, connector, scope).run();
32+
}
33+
34+
export async function revokeConsent(env: Env, instanceId: string, connector: string, scope: ConnectorScope): Promise<void> {
35+
await env.DB.prepare(
36+
"DELETE FROM instance_connector_consent WHERE instance_id = ?1 AND connector = ?2 AND scope = ?3",
37+
).bind(instanceId, connector, scope).run();
38+
}
39+
40+
export interface ConsentRow { instance_id: string; user_id: string; connector: string; scope: string; created_at: string }
41+
42+
export async function listConsents(env: Env, instanceId: string): Promise<ConsentRow[]> {
43+
const res = await env.DB.prepare(
44+
"SELECT instance_id, user_id, connector, scope, created_at FROM instance_connector_consent WHERE instance_id = ?1",
45+
).bind(instanceId).all<ConsentRow>();
46+
return res.results ?? [];
47+
}
48+
49+
/** Admin: every connector consent across all users (with owner login). */
50+
export async function listAllConsents(env: Env): Promise<Array<ConsentRow & { owner_login: string | null }>> {
51+
const res = await env.DB.prepare(
52+
`SELECT c.instance_id, c.user_id, c.connector, c.scope, c.created_at, u.github_login AS owner_login
53+
FROM instance_connector_consent c LEFT JOIN users u ON u.id = c.user_id
54+
ORDER BY c.created_at DESC`,
55+
).all<ConsentRow & { owner_login: string | null }>();
56+
return res.results ?? [];
57+
}

‎workers/api/src/lib/connectors/github.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,32 @@ export const GITHUB_TOOLS: RegistryTool[] = [
9898
return issue ? { content: JSON.stringify(issue, null, 2), success: true } : { content: `Issue #${num} not found in ${repo}.`, success: false };
9999
},
100100
},
101+
{
102+
name: "github_create_issue",
103+
connector: "github",
104+
scope: "write",
105+
description: "Open a new GitHub issue in a repo. WRITE — requires the GitHub connector's write consent for this instance.",
106+
parameters: {
107+
repo: { type: "string", description: 'The repository, "owner/name".', required: true },
108+
title: { type: "string", description: "Issue title.", required: true },
109+
body: { type: "string", description: "Issue body (markdown)." },
110+
labels: { type: "string", description: "Comma-separated labels to apply." },
111+
},
112+
handler: async (ctx, input) => {
113+
const repo = String(input.repo || "");
114+
const title = String(input.title || "").trim();
115+
if (!title) return { content: "An issue `title` is required.", success: false };
116+
const r = await resolveRepo(ctx, repo);
117+
if ("error" in r) return { content: r.error, success: false };
118+
const labels = String(input.labels || "").split(",").map((s) => s.trim()).filter(Boolean);
119+
const res = await fetch(`https://api.github.com/repos/${repo}/issues`, {
120+
method: "POST",
121+
headers: { ...GH(r.token), "Content-Type": "application/json" },
122+
body: JSON.stringify({ title, body: input.body ? String(input.body) : undefined, ...(labels.length ? { labels } : {}) }),
123+
});
124+
if (!res.ok) return { content: `GitHub returned ${res.status} creating the issue in ${repo}`, success: false };
125+
const data = (await res.json()) as { number?: number; html_url?: string };
126+
return { content: `Opened issue #${data.number} — ${data.html_url}`, success: true };
127+
},
128+
},
101129
];

‎workers/api/src/lib/tool-registry.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ import type { Env } from "../types.js";
44

55
const envNoGithub = {} as unknown as Env; // githubAppConfigured() → false
66

7+
/** Env whose consent lookup returns `granted` for every (instance,connector,write). */
8+
function envWithConsent(granted: boolean): Env {
9+
return {
10+
DB: {
11+
prepare(_sql: string) {
12+
return { bind() { return { first: async () => (granted ? { ok: 1 } : null) }; } };
13+
},
14+
},
15+
} as unknown as Env;
16+
}
17+
718
describe("tool registry", () => {
819
it("registers the GitHub connector tools", () => {
920
const names = registryToolNameSet();
@@ -37,6 +48,26 @@ describe("tool registry", () => {
3748

3849
it("getRegistryTool returns the tool with its scope", () => {
3950
expect(getRegistryTool("github_workflow_runs")?.scope).toBe("read");
51+
expect(getRegistryTool("github_create_issue")?.scope).toBe("write");
4052
expect(getRegistryTool("does_not_exist")).toBeUndefined();
4153
});
54+
55+
it("blocks a WRITE tool without consent (fail-closed)", async () => {
56+
const r = await runRegistryTool("github_create_issue", { env: envWithConsent(false), userId: "u1", instanceId: "i1" }, { repo: "o/n", title: "hi" });
57+
expect(r.success).toBe(false);
58+
expect(r.content).toMatch(/isn't permitted|not permitted|consent/i);
59+
});
60+
61+
it("blocks a WRITE tool when there's no instance context (fail-closed)", async () => {
62+
const r = await runRegistryTool("github_create_issue", { env: envWithConsent(true), userId: "u1" }, { repo: "o/n", title: "hi" });
63+
expect(r.success).toBe(false);
64+
});
65+
66+
it("passes the consent gate for a WRITE tool when granted (then fails later on not-connected)", async () => {
67+
const r = await runRegistryTool("github_create_issue", { env: envWithConsent(true), userId: "u1", instanceId: "i1" }, { repo: "o/n", title: "hi" });
68+
// Gate passed → handler ran → GitHub not configured in this env → its own error,
69+
// NOT the consent error.
70+
expect(r.content).not.toMatch(/isn't permitted/i);
71+
expect(r.content).toMatch(/not connected|not configured/i);
72+
});
4273
});

‎workers/api/src/lib/tool-registry.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// STORAGE_TOOLS catalog is untouched; registry tools are dispatched alongside them.
66
import type { Env } from "../types.js";
77
import { GITHUB_TOOLS } from "./connectors/github.js";
8+
import { hasConsent } from "./connector-consent.js";
89

910
export interface RegistryToolCtx {
1011
env: Env;
@@ -71,6 +72,15 @@ export async function runRegistryTool(
7172
): Promise<{ name: string; content: string; success: boolean }> {
7273
const tool = REGISTRY.get(name);
7374
if (!tool) return { name, content: `Unknown tool: ${name}`, success: false };
75+
// Write-consent gate (issue #90): a write tool needs explicit per-instance consent
76+
// for its connector. Fail-closed — no instance context or no consent → refused.
77+
if (tool.scope === "write" && !(await hasConsent(ctx.env, ctx.instanceId, tool.connector, "write"))) {
78+
return {
79+
name,
80+
content: `Writing via the ${tool.connector} connector isn't permitted for this agent. Enable write access for ${tool.connector} in the instance's Connections settings, then try again.`,
81+
success: false,
82+
};
83+
}
7484
try {
7585
const r = await tool.handler(ctx, input || {});
7686
return { name, content: r.content, success: r.success };

‎workers/api/src/routes/admin.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Context } from "hono";
33
import { HttpError, isAdmin, requireAdmin, requireUser } from "../lib/auth.js";
44
import { getOverviewStats, getUserDetail, listAdminAudit, listAgents, listInstances, listUsers } from "../lib/admin.js";
55
import { summarizeErrors, type RawError } from "../lib/admin-errors.js";
6+
import { listAllConsents } from "../lib/connector-consent.js";
67
import { aggregateAdminUsage, type AdminUsageRow } from "../lib/usage.js";
78
import { groupTerminalNodes } from "./terminals.js";
89
import { relayConnected } from "../lib/runner-client.js";
@@ -87,6 +88,12 @@ adminRoutes.get("/audit", async (c) => {
8788
return c.json({ count: rows.length, audit: rows });
8889
});
8990

91+
/** GET /v1/admin/connectors — every connector write-consent across all users (visibility, #90). */
92+
adminRoutes.get("/connectors", async (c) => {
93+
await requireAdmin(c);
94+
return c.json({ consents: await listAllConsents(c.env) });
95+
});
96+
9097
/** GET /v1/admin/overview — one-shot dashboard counts (users/agents/instances/errors/spend). */
9198
adminRoutes.get("/overview", async (c) => {
9299
await requireAdmin(c);

‎workers/api/src/routes/tools.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Hono } from "hono";
22
import { HttpError, requireUser } from "../lib/auth.js";
33
import { requireOwnedInstance } from "./instances-runtime.js";
44
import { getRegistryTool, registryTools, runRegistryTool } from "../lib/tool-registry.js";
5+
import { listConsents, revokeConsent, setConsent } from "../lib/connector-consent.js";
56
import type { Env } from "../types.js";
67

78
/**
@@ -37,3 +38,26 @@ toolRoutes.post("/:id/tools/:name", async (c) => {
3738
const result = await runRegistryTool(name, { env: c.env, userId: session.uid, instanceId }, input);
3839
return c.json(result);
3940
});
41+
42+
/** GET /v1/instances/:id/connectors/consent — write-consents granted on this instance. */
43+
toolRoutes.get("/:id/connectors/consent", async (c) => {
44+
const session = await requireUser(c);
45+
await requireOwnedInstance(c.env, c.req.param("id"), session.uid);
46+
return c.json({ consents: await listConsents(c.env, c.req.param("id")) });
47+
});
48+
49+
/**
50+
* PUT /v1/instances/:id/connectors/:connector/consent { enabled } — grant/revoke
51+
* write consent for a connector (issue #90). Owner-scoped. Scope is "write" (reads
52+
* never need consent).
53+
*/
54+
toolRoutes.put("/:id/connectors/:connector/consent", async (c) => {
55+
const session = await requireUser(c);
56+
const instanceId = c.req.param("id");
57+
await requireOwnedInstance(c.env, instanceId, session.uid);
58+
const connector = c.req.param("connector");
59+
const body = (await c.req.json().catch(() => ({}))) as { enabled?: boolean };
60+
if (body.enabled) await setConsent(c.env, instanceId, session.uid, connector, "write");
61+
else await revokeConsent(c.env, instanceId, connector, "write");
62+
return c.json({ ok: true, connector, scope: "write", enabled: !!body.enabled });
63+
});

0 commit comments

Comments
 (0)