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
5 changes: 5 additions & 0 deletions .changeset/grok-approval-notices.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions src/cli/approvals/list.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof inputSchema>) {
const out = await listOperation(input);
return <Agent.Result value={out}><Agent.Text>{JSON.stringify(out)}</Agent.Text></Agent.Result>;
}
17 changes: 17 additions & 0 deletions src/cli/approvals/respond.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof inputSchema>) {
const out = await respondOperation(input);
return <Agent.Result value={out}><Agent.Text>{JSON.stringify(out)}</Agent.Text></Agent.Result>;
}
26 changes: 26 additions & 0 deletions src/core/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -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" };
}
14 changes: 14 additions & 0 deletions src/core/grok-approval-routes.ts
Original file line number Diff line number Diff line change
@@ -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<typeof listSchema>) => withRedactedErrors(async () =>
listGrokApprovals(await connectGateway(), listSchema.parse(input).target));
export const respondOperation = (input: z.infer<typeof respondSchema>) => withRedactedErrors(async () => {
const { target, ...response } = respondSchema.parse(input);
return respondGrokApproval(await connectGateway(), target, response);
});
32 changes: 32 additions & 0 deletions src/core/grok-approvals.js
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 6 additions & 3 deletions src/core/relay/intake.js
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify approval cards before suppressing return echoes

When Grok raises an approval while processing an automatically returned Codex answer, the card's outer requestId matches a grok-return ID in own, so the preceding guard skips the entry before grokApprovalNotice runs. The poll then advances its cursor, permanently hiding that pending approval from Codex. Approval cards need to be classified before applying the normal own-response suppression.

Useful? React with 👍 / 👎.

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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/mcp/grok-bot/tools/gbot_grok_approvals.tsx
Original file line number Diff line number Diff line change
@@ -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 <Agent.Result value={out}><Agent.Text>{JSON.stringify(out)}</Agent.Text></Agent.Result>;
});
17 changes: 17 additions & 0 deletions src/mcp/grok-bot/tools/gbot_grok_respond.tsx
Original file line number Diff line number Diff line change
@@ -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 <Agent.Result value={out}><Agent.Text>{JSON.stringify(out)}</Agent.Text></Agent.Result>;
});
10 changes: 10 additions & 0 deletions src/skills/talk-to-grok-bot/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions test/grok-approvals.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
20 changes: 20 additions & 0 deletions test/relay-engine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
30 changes: 29 additions & 1 deletion tests/route-unit/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ const transcripts: Record<string, unknown> = {
};
const responses: Record<string, (body: Record<string, unknown>) => [number, unknown]> = {
getAgentTranscriptTail: (body) => [200, transcripts[String(body.id)]],
resolveAutoReviewApproval: () => [200, {}],
resolveLocalToolPermission: () => [200, {}],
listAgents: () => [200, roster],
sendPrompt: (body) =>
body.agentId === 'bot-3'
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading