From eaf0074ece6e255d6bac665b1c745be950c285d3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 23:25:10 +0000 Subject: [PATCH] feat: relay Grok approval notices and explicit one-time responses --- .changeset/grok-approval-notices.md | 5 ++ src/cli/approvals/list.tsx | 12 +++++ src/cli/approvals/respond.tsx | 17 ++++++ src/core/gateway.js | 26 ++++++++++ src/core/grok-approval-routes.ts | 14 +++++ src/core/grok-approvals.js | 32 ++++++++++++ src/core/relay/intake.js | 9 ++-- .../grok-bot/tools/gbot_grok_approvals.tsx | 12 +++++ src/mcp/grok-bot/tools/gbot_grok_respond.tsx | 17 ++++++ src/skills/talk-to-grok-bot/SKILL.md | 10 ++++ test/grok-approvals.test.js | 52 +++++++++++++++++++ test/relay-engine.test.js | 20 +++++++ tests/route-unit/tools.test.ts | 30 ++++++++++- 13 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 .changeset/grok-approval-notices.md create mode 100644 src/cli/approvals/list.tsx create mode 100644 src/cli/approvals/respond.tsx create mode 100644 src/core/grok-approval-routes.ts create mode 100644 src/core/grok-approvals.js create mode 100644 src/mcp/grok-bot/tools/gbot_grok_approvals.tsx create mode 100644 src/mcp/grok-bot/tools/gbot_grok_respond.tsx create mode 100644 test/grok-approvals.test.js diff --git a/.changeset/grok-approval-notices.md b/.changeset/grok-approval-notices.md new file mode 100644 index 0000000..04f92a0 --- /dev/null +++ b/.changeset/grok-approval-notices.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": minor +--- + +Forward pending Grok auto-review and local-tool approval cards as Codex notices without treating chat replies as authorization. Add CLI and MCP commands to inspect requests and explicitly accept once or decline an exact current request. diff --git a/src/cli/approvals/list.tsx b/src/cli/approvals/list.tsx new file mode 100644 index 0000000..8e01517 --- /dev/null +++ b/src/cli/approvals/list.tsx @@ -0,0 +1,12 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { listSchema as inputSchema, resultSchema, listOperation } from '../../core/grok-approval-routes.js'; +export { inputSchema, resultSchema }; +export const config = { + description: 'List current Grok approval cards (latest 200 entries).', positionals: ['target'], + inputJsonSchema: { type: 'object', properties: { target: { type: 'string' } }, required: ['target'], additionalProperties: false }, +} satisfies CliRouteConfig; +export default async function route({ input }: CliRouteProps) { + const out = await listOperation(input); + return {JSON.stringify(out)}; +} diff --git a/src/cli/approvals/respond.tsx b/src/cli/approvals/respond.tsx new file mode 100644 index 0000000..7add22c --- /dev/null +++ b/src/cli/approvals/respond.tsx @@ -0,0 +1,17 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { respondSchema as inputSchema, resultSchema, respondOperation } from '../../core/grok-approval-routes.js'; +export { inputSchema, resultSchema }; +export const config = { + description: 'Explicit user decision: accept a Grok request once or decline; never grant persistent permissions.', + inputJsonSchema: { + type: 'object', properties: { + target: { type: 'string' }, entryId: { type: 'string' }, requestId: { type: 'string' }, + decision: { type: 'string', enum: ['accept', 'decline'] }, + }, required: ['target', 'entryId', 'requestId', 'decision'], additionalProperties: false, + }, +} satisfies CliRouteConfig; +export default async function route({ input }: CliRouteProps) { + const out = await respondOperation(input); + return {JSON.stringify(out)}; +} diff --git a/src/core/gateway.js b/src/core/gateway.js index 32366e4..f9553c5 100644 --- a/src/core/gateway.js +++ b/src/core/gateway.js @@ -3,6 +3,7 @@ import { ensureSandboxHeaders, headersFromEnsureSandbox, headersFromEnv, mergeGa import { hasGrokBotGatewaySession, loadGrokBotGatewaySession } from "./app-session.js"; import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS } from "./store.js"; import { assertAllowedCredentialUrl, redactSecrets } from "./url-policy.js"; +import { grokApproval, grokApprovalResponseSchema } from "./grok-approvals.js"; class GatewayError extends Error { constructor(message, { status, method } = {}) { @@ -404,3 +405,28 @@ export async function getThread(session, ref, rootId) { const data = await gatewayCall(session, "getAgentThread", { id: rec.id, rootId }); return { target: rec, thread: data }; } + +export async function listGrokApprovals(session, ref) { + const { target, transcript } = await getTranscriptTail(session, ref, 200); + if (!Array.isArray(transcript?.entries) || transcript.entries.length > 200) throw new GatewayError("Invalid approval transcript coverage"); + return { + target: { id: target.id, name: target.name }, + approvals: transcript.entries.map(grokApproval).filter(Boolean), + coverage: "Latest 200 transcript entries only; older requests require the owning Grok UI.", + }; +} + +export async function respondGrokApproval(session, ref, input) { + const { entryId, requestId, decision } = grokApprovalResponseSchema.parse({ ...input, target: ref }); + const { target, approvals } = await listGrokApprovals(session, ref); + const matches = approvals.filter(card => card.entryId === entryId && card.requestId === requestId); + if (matches.length !== 1) throw new GatewayError("Stale, foreign or unsupported Grok approval; refresh pending requests or use the owning Grok UI"); + const approval = matches[0]; + if (decision === "accept" && approval.truncated) throw new GatewayError("Approval details are truncated; acceptance requires the owning Grok UI"); + const local = approval.type === "local-tool-permission"; + await gatewayCall(session, local ? "resolveLocalToolPermission" : "resolveAutoReviewApproval", { + agentId: target.id, entryId, requestId, + resolution: local ? (decision === "accept" ? "allow-once" : "deny") : (decision === "accept" ? "approved" : "denied"), + }); + return { target, entryId, requestId, decision, delivery: "accepted" }; +} diff --git a/src/core/grok-approval-routes.ts b/src/core/grok-approval-routes.ts new file mode 100644 index 0000000..7b3f29c --- /dev/null +++ b/src/core/grok-approval-routes.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { connectGateway, listGrokApprovals, respondGrokApproval } from './gateway.js'; +import { grokApprovalResponseSchema } from './grok-approvals.js'; +import { withRedactedErrors } from '../gbot.js'; + +export const listSchema = z.strictObject({ target: z.string().min(1).max(1024) }); +export const respondSchema = grokApprovalResponseSchema; +export const resultSchema = z.record(z.string(), z.json()); +export const listOperation = (input: z.infer) => withRedactedErrors(async () => + listGrokApprovals(await connectGateway(), listSchema.parse(input).target)); +export const respondOperation = (input: z.infer) => withRedactedErrors(async () => { + const { target, ...response } = respondSchema.parse(input); + return respondGrokApproval(await connectGateway(), target, response); +}); diff --git a/src/core/grok-approvals.js b/src/core/grok-approvals.js new file mode 100644 index 0000000..0e1cb65 --- /dev/null +++ b/src/core/grok-approvals.js @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { redactSecrets } from "./url-policy.js"; + +const id = z.string().min(1).max(1024); +export const grokApprovalResponseSchema = z.strictObject({ + target: id, + entryId: id, + requestId: id, + decision: z.enum(["accept", "decline"]), +}); + +export function grokApproval(entry) { + if (entry?.kind !== "send-message") return null; + const type = entry.message?.type; + const card = type === "auto-review-approval" ? entry.message.approval + : type === "local-tool-permission" ? entry.message.ask : null; + if (card?.status !== "pending" || !id.safeParse(card.requestId).success || !id.safeParse(entry.id).success) return null; + const details = {}; + let truncated = false; + for (const key of ["reason", "command", "summary", "action", "description", "target", "machineId", "surface", "workingDirectory"]) { + if (typeof card[key] !== "string") continue; + const text = redactSecrets(card[key]); + truncated ||= text.length > 2048; + details[key] = text.slice(0, 2048); + } + return { entryId: entry.id, requestId: card.requestId, type, ...details, truncated }; +} + +export function grokApprovalNotice(entry, target) { + const approval = grokApproval(entry); + return approval ? `Grok approval pending. Requires an explicit user decision; never approve automatically.\n${JSON.stringify({ botTarget: target, ...approval })}\nUse gbot_grok_respond with target=${JSON.stringify(target)}, entryId, requestId and decision accept (once) or decline.${approval.truncated ? " Details are truncated; acceptance requires the owning Grok UI." : ""} A chat reply is not authorization.` : null; +} diff --git a/src/core/relay/intake.js b/src/core/relay/intake.js index b471131..9bd4a9a 100644 --- a/src/core/relay/intake.js +++ b/src/core/relay/intake.js @@ -1,5 +1,6 @@ import { entryText, sourceEntryId } from "../transcript.js"; import { op, hash, MAX_TEXT, pageEntries, messageText } from "./records.js"; +import { grokApprovalNotice } from "../grok-approvals.js"; /** Correlate the whole page before atomically recording intake and advancing its checkpoint. */ export function createIntake({ @@ -141,6 +142,8 @@ export function createIntake({ const incoming = page.slice(index + 1); for (const entry of incoming) { if (entry.kind !== "send-message" || own.has(entry.requestId)) continue; + const approvalNotice = grokApprovalNotice(entry, targetId); + if (["auto-review-approval", "local-tool-permission"].includes(entry.message?.type) && !approvalNotice) continue; const parent = requests.get(entry.requestId); // Without nonce coverage, unsolicited classification could echo a return or misroute a reply. if (!parent && unresolved) { @@ -169,7 +172,7 @@ export function createIntake({ if (local[id]) continue; let body; try { - body = messageText(entryText(entry)); + body = messageText(approvalNotice ?? entryText(entry)); } catch { await change("targets", { ...target, @@ -178,7 +181,7 @@ export function createIntake({ }); return; } - const text = `[Grok sender ${targetId}; message ${sourceId}]\n${parent ? "Reply to a tracked request. Your next final answer is not returned automatically." : "Linked conversation. Your corresponding final answer returns automatically to Grok."}\n\n${body}`; + const text = `[Grok sender ${targetId}; message ${sourceId}]\n${approvalNotice ? "Approval notice. Your next final answer is not returned automatically." : parent ? "Reply to a tracked request. Your next final answer is not returned automatically." : "Linked conversation. Your corresponding final answer returns automatically to Grok."}\n\n${body}`; if (Buffer.byteLength(text) > MAX_TEXT) { await change("targets", { ...target, @@ -190,7 +193,7 @@ export function createIntake({ const record = newRecord("codex", id, destination, text, { sourceIds: [sourceId], parentId: parent?.id, - returnToGrok: !parent, + returnToGrok: !parent && !approvalNotice, correlationId: parent?.correlationId, hop: parent ? parent.hop + 1 : 0, maxHops: parent?.maxHops, diff --git a/src/mcp/grok-bot/tools/gbot_grok_approvals.tsx b/src/mcp/grok-bot/tools/gbot_grok_approvals.tsx new file mode 100644 index 0000000..90d630f --- /dev/null +++ b/src/mcp/grok-bot/tools/gbot_grok_approvals.tsx @@ -0,0 +1,12 @@ +import { Agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { listSchema as inputSchema, resultSchema, listOperation } from '../../../core/grok-approval-routes.js'; +export { inputSchema }; +export default defineTool({ + title: 'Pending Grok approvals', description: 'List pending auto-review and local-tool approval cards in the latest 200 entries for a Grok bot. Older or unsupported requests require the owning Grok UI.', + annotations: { readOnlyHint: true }, inputSchema, resultSchema, + inputJsonSchema: { type: 'object', properties: { target: { type: 'string' } }, required: ['target'], additionalProperties: false }, +}, async input => { + const out = await listOperation(input); + return {JSON.stringify(out)}; +}); diff --git a/src/mcp/grok-bot/tools/gbot_grok_respond.tsx b/src/mcp/grok-bot/tools/gbot_grok_respond.tsx new file mode 100644 index 0000000..529f917 --- /dev/null +++ b/src/mcp/grok-bot/tools/gbot_grok_respond.tsx @@ -0,0 +1,17 @@ +import { Agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { respondSchema as inputSchema, resultSchema, respondOperation } from '../../../core/grok-approval-routes.js'; +export { inputSchema }; +export default defineTool({ + title: 'Respond to Grok approval', description: 'Only after an explicit user decision: accept one current Grok approval once or decline it. Exact target, entryId and approval requestId required. Never auto-approve or grant persistent permissions. Success acknowledges response delivery, not execution.', + annotations: { readOnlyHint: false }, inputSchema, resultSchema, + inputJsonSchema: { + type: 'object', properties: { + target: { type: 'string' }, entryId: { type: 'string' }, requestId: { type: 'string' }, + decision: { type: 'string', enum: ['accept', 'decline'] }, + }, required: ['target', 'entryId', 'requestId', 'decision'], additionalProperties: false, + }, +}, async input => { + const out = await respondOperation(input); + return {JSON.stringify(out)}; +}); diff --git a/src/skills/talk-to-grok-bot/SKILL.md b/src/skills/talk-to-grok-bot/SKILL.md index 46dfa6a..2fc3f6e 100644 --- a/src/skills/talk-to-grok-bot/SKILL.md +++ b/src/skills/talk-to-grok-bot/SKILL.md @@ -44,6 +44,16 @@ change session permissions, or respond to an unsupported interaction; use its ow Bot replies are `send-message` entries; yours are `message` with `role: user`. +Grok-origin approvals are separate from Codex interactions. `gbot_grok_approvals` +(CLI: `gbot approvals list TARGET`) lists pending auto-review and local-tool cards in +the latest 200 entries. Linked/tracked routes forward new pending cards as notices; +their chat answers never authorize an action. After an explicit user decision, use +`gbot_grok_respond` (CLI: `gbot approvals respond --target TARGET --entry-id ID +--request-id ID --decision accept|decline`). Copy the exact IDs from the current +card. Accept grants once; persistent grants are unavailable. Responses recheck the +card before sending; delivery success does not prove execution. Older cards, +cookie/payment approvals and other unsupported requests require the owning Grok UI. + List targets with `gbot bots list` / `gbot groups list` when the name is ambiguous. ## CLI automation diff --git a/test/grok-approvals.test.js b/test/grok-approvals.test.js new file mode 100644 index 0000000..6543aa4 --- /dev/null +++ b/test/grok-approvals.test.js @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { listGrokApprovals, respondGrokApproval } from "../src/core/gateway.js"; + +test("Grok responses require a current exact card and only grant once or reject", async t => { + const original = globalThis.fetch; + const testEnv = process.env.GROK_BOT_TEST; + process.env.GROK_BOT_TEST = '1'; + t.after(() => { + globalThis.fetch = original; + if (testEnv === undefined) delete process.env.GROK_BOT_TEST; + else process.env.GROK_BOT_TEST = testEnv; + }); + const calls = []; + const approval = { requestId: "ask", status: "pending", command: "npm publish" }; + const entries = [{ id: "card", kind: "send-message", message: { type: "auto-review-approval", approval } }]; + globalThis.fetch = async (url, options) => { + const method = url.split("/").at(-1); + const body = JSON.parse(options.body); + calls.push({ method, body }); + const result = method === "listAgents" ? { agents: [{ id: "bot", name: "Router" }] } + : method === "getAgentTranscriptTail" ? { entries } : {}; + return new Response(JSON.stringify(result)); + }; + const session = { gatewayUrl: "http://127.0.0.1:1", gatewayToken: "fixture" }; + assert.equal((await listGrokApprovals(session, "bot")).approvals[0].requestId, "ask"); + for (const patch of [{ requestId: "foreign" }, { entryId: "foreign" }, { decision: "always" }]) { + await assert.rejects(respondGrokApproval(session, "bot", { entryId: "card", requestId: "ask", decision: "accept", ...patch })); + } + assert.equal(calls.filter(c => c.method.startsWith("resolve")).length, 0); + await respondGrokApproval(session, "bot", { entryId: "card", requestId: "ask", decision: "accept" }); + assert.deepEqual(calls.at(-1), { method: "resolveAutoReviewApproval", body: { + agentId: "bot", entryId: "card", requestId: "ask", resolution: "approved", + } }); + approval.status = "expired"; + await assert.rejects(respondGrokApproval(session, "bot", { entryId: "card", requestId: "ask", decision: "accept" })); + entries[0].message = { type: "local-tool-permission", ask: { requestId: "local", status: "pending", action: "run-command", target: "rm ./output.txt", machineId: "ubuntu" } }; + assert.deepEqual((await listGrokApprovals(session, "bot")).approvals[0], { + entryId: "card", requestId: "local", type: "local-tool-permission", action: "run-command", + target: "rm ./output.txt", machineId: "ubuntu", truncated: false, + }); + await respondGrokApproval(session, "bot", { entryId: "card", requestId: "local", decision: "decline" }); + assert.equal(calls.at(-1).method, "resolveLocalToolPermission"); + assert.equal(calls.at(-1).body.resolution, "deny"); + await respondGrokApproval(session, "bot", { entryId: "card", requestId: "local", decision: "accept" }); + assert.equal(calls.at(-1).body.resolution, "allow-once"); + entries[0].message.ask.target = "x".repeat(3000); + assert.equal((await listGrokApprovals(session, "bot")).approvals[0].truncated, true); + const before = calls.filter(c => c.method.startsWith("resolve")).length; + await assert.rejects(respondGrokApproval(session, "bot", { entryId: "card", requestId: "local", decision: "accept" }), /truncated/); + assert.equal(calls.filter(c => c.method.startsWith("resolve")).length, before); +}); diff --git a/test/relay-engine.test.js b/test/relay-engine.test.js index 433e715..b42980f 100644 --- a/test/relay-engine.test.js +++ b/test/relay-engine.test.js @@ -160,6 +160,26 @@ test("linked empty baseline forwards once, returns final and suppresses out-of-o ); assert.equal(f.sent.length, 1); }); +test("Grok approval cards notify Codex once without returning its answer as authorization", async (t) => { + const f = await fixture(t); + await f.engine.startBinding({ grokTarget: "target", codexThreadId: "thread" }); + f.page.push({ + id: "approval-card", kind: "send-message", requestId: "run", + message: { type: "auto-review-approval", approval: { + requestId: "approval-request", status: "pending", command: "npm publish", + } }, + }); + await f.engine.tick(); + await f.engine.tick(); + assert.equal(f.engine.status().targets[0].state, "running"); + const notices = f.fake.received.filter(r => r.method === "turn/start"); + assert.equal(notices.length, 1); + const text = notices[0].params.input[0].text; + assert.match(text, /approval-request/); + assert.match(text, /gbot_grok_respond/); + assert.match(text, /explicit user decision/i); + assert.equal(f.sent.length, 0); +}); test("tracked request uses actual requestId and never forwards another thread reply", async (t) => { const f = await fixture(t); const r = await f.engine.sendToGrok({ diff --git a/tests/route-unit/tools.test.ts b/tests/route-unit/tools.test.ts index dff3e4d..8d17363 100644 --- a/tests/route-unit/tools.test.ts +++ b/tests/route-unit/tools.test.ts @@ -73,6 +73,8 @@ const transcripts: Record = { }; const responses: Record) => [number, unknown]> = { getAgentTranscriptTail: (body) => [200, transcripts[String(body.id)]], + resolveAutoReviewApproval: () => [200, {}], + resolveLocalToolPermission: () => [200, {}], listAgents: () => [200, roster], sendPrompt: (body) => body.agentId === 'bot-3' @@ -131,7 +133,33 @@ beforeEach(() => { describe('grok-bot MCP server', () => { it('registers messaging, conversation and managed bridge tools', async () => { const surface = await listMcpSurface({ server: 'grok-bot' }); - expect([...surface.tools].sort()).toEqual(['codex_send', 'codex_threads', 'codex_wait', 'codex_watch', 'gbot_bridge_start', 'gbot_bridge_status', 'gbot_bridge_stop', 'gbot_codex_respond', 'gbot_send', 'gbot_thread']); + expect([...surface.tools].sort()).toEqual(['codex_send', 'codex_threads', 'codex_wait', 'codex_watch', 'gbot_bridge_start', 'gbot_bridge_status', 'gbot_bridge_stop', 'gbot_codex_respond', 'gbot_grok_approvals', 'gbot_grok_respond', 'gbot_send', 'gbot_thread']); + }); + + it('lists and responds to an exact current Grok approval through the native API', async () => { + transcripts['bot-1'] = { entries: [{ id: 'card', kind: 'send-message', message: { + type: 'auto-review-approval', approval: { requestId: 'approval', status: 'pending', command: 'echo test' }, + } }] }; + try { + const listed = await invokeMcpTool('gbot_grok_approvals', { server: 'grok-bot', input: { target: 'General' } }); + expect(listed.isError).toBe(false); + expect(listed.structuredContent).toMatchObject({ approvals: [{ entryId: 'card', requestId: 'approval' }] }); + const result = await invokeMcpTool('gbot_grok_respond', { + server: 'grok-bot', input: { target: 'General', entryId: 'card', requestId: 'approval', decision: 'decline' }, + }); + expect(result.isError).toBe(false); + expect(calls.at(-1)).toMatchObject({ method: 'resolveAutoReviewApproval', body: { + agentId: 'bot-1', entryId: 'card', requestId: 'approval', resolution: 'denied', + } }); + transcripts['bot-1'] = { entries: [] }; + const stale = await invokeMcpTool('gbot_grok_respond', { + server: 'grok-bot', input: { target: 'General', entryId: 'card', requestId: 'approval', decision: 'accept' }, + }); + expect(stale.isError).toBe(true); + expect(calls.filter(call => call.method.startsWith('resolve'))).toHaveLength(1); + } finally { + transcripts['bot-1'] = { entries: [], nextBeforeSeq: 0 }; + } }); it('gbot_send resolves the target by name and posts the prompt with the gateway token', async () => {