test/claude-permission-mode-explicit.test.js
Rama main · réplica de lectura del repositorio original.
var test = require("node:test");
var assert = require("node:assert/strict");
var fs = require("fs");
var createSDKBridge = require("../lib/sdk-bridge").createSDKBridge;
var claudeAdapterModule = require("../lib/yoke/adapters/claude");
var createClaudeAdapter = claudeAdapterModule.createClaudeAdapter;
var buildWorkerQueryOptions = claudeAdapterModule.contractTestKit.buildWorkerQueryOptions;
// Claude SDK 0.3.286+ leaves an omitted permissionMode to Claude Code's own
// settings defaultMode, which can start a session in auto mode (no asking)
// on third-party providers or with telemetry off. Clay must always send an
// explicit permissionMode so a plain Ask session is never silently widened.
function sdkFixture(captures) {
return {
query: function (args) {
captures.push(args.options);
return {
close: function () {},
setPermissionMode: function () {},
[Symbol.asyncIterator]: function () { return { next: function () { return Promise.resolve({ done: true }); } }; },
};
},
};
}
function baseSessionManager(sessions) {
return {
sessions: sessions,
availableModels: [],
modelsByVendor: { claude: [] },
capabilitiesByVendor: { claude: {} },
saveSessionFile: function () {},
appendToSessionFile: function () {},
broadcastSessionList: function () {},
sendAndRecord: function () {},
sendToSession: function () {},
};
}
test("a plain GUI session always sends an explicit permissionMode, never omitted", async function () {
var captures = [];
var sdk = sdkFixture(captures);
var adapter = createClaudeAdapter({ cwd: process.cwd(), loadSDK: function () { return Promise.resolve(sdk); } });
var session = { localId: 201, vendor: "claude", mode: "gui", history: [], pendingAskUser: {}, pendingPermissions: {}, pendingElicitations: {} };
var sm = baseSessionManager(new Map([[201, session]]));
var bridge = createSDKBridge({ cwd: process.cwd(), sessionManager: sm, adapter: adapter, adapters: { claude: adapter }, send: function () {} });
await bridge.startQuery(session, "hello", null, null);
assert.equal(captures.length, 1);
assert.equal(captures[0].permissionMode, "default", "Ask/default must be sent explicitly, not omitted");
});
test("acceptEditsAfterStart still applies explicitly, not left to the CLI default", async function () {
var captures = [];
var sdk = sdkFixture(captures);
var adapter = createClaudeAdapter({ cwd: process.cwd(), loadSDK: function () { return Promise.resolve(sdk); } });
var session = { localId: 202, vendor: "claude", mode: "gui", acceptEditsAfterStart: true, history: [], pendingAskUser: {}, pendingPermissions: {}, pendingElicitations: {} };
var sm = baseSessionManager(new Map([[202, session]]));
var bridge = createSDKBridge({ cwd: process.cwd(), sessionManager: sm, adapter: adapter, adapters: { claude: adapter }, send: function () {} });
await bridge.startQuery(session, "hello", null, null);
assert.equal(captures[0].permissionMode, "acceptEdits");
});
test("handleCanUseTool forwards the SDK-reported mcpServer name/source to the client, trusting only the SDK's opts", async function () {
var recorded = [];
var session = { localId: 301, permissionMode: "default", pendingPermissions: {} };
var bridge = createSDKBridge({
cwd: process.cwd(),
sessionManager: { currentPermissionMode: "default", permissionRequestIndex: {}, sendAndRecord: function (target, message) { recorded.push(message); } },
adapter: { vendor: "claude" },
send: function () {},
onProcessingChanged: function () {},
});
// mcpServer arrives via the SDK's trusted opts, never via the tool's own input.
// "mcp__external-server__query" is not in Clay's auto-approve whitelist,
// so this must reach the real permission_request path.
var decisionPromise = bridge.handleCanUseTool(session, "mcp__external-server__query", { title: "spoofed", mcpServer: { name: "attacker", source: "config" } }, {
toolUseID: "tu1",
mcpServer: { name: "external-server", source: "project" },
});
var request = recorded.find(function (m) { return m.type === "permission_request"; });
assert.ok(request, "a permission_request must be sent");
assert.notEqual(request.requestId, "tu1", "requestId is a freshly generated uuid, not the tool_use_id");
assert.deepEqual(request.mcpServer, { name: "external-server", source: "project" });
var pending = session.pendingPermissions[request.requestId];
assert.ok(pending, "the pending entry must be keyed by requestId");
assert.deepEqual(pending.mcpServer, { name: "external-server", source: "project" });
pending.resolve({ behavior: "deny", message: "test cleanup" });
assert.deepEqual(await decisionPromise, { behavior: "deny", message: "test cleanup" });
});
test("handleCanUseTool sends mcpServer: null rather than omitting it when the SDK reports none", async function () {
var recorded = [];
var session = { localId: 302, permissionMode: "default", pendingPermissions: {} };
var bridge = createSDKBridge({
cwd: process.cwd(),
sessionManager: { currentPermissionMode: "default", permissionRequestIndex: {}, sendAndRecord: function (target, message) { recorded.push(message); } },
adapter: { vendor: "claude" },
send: function () {},
onProcessingChanged: function () {},
});
var decisionPromise = bridge.handleCanUseTool(session, "Write", { file_path: "/tmp/x" }, { toolUseID: "tu2" });
var request = recorded.find(function (m) { return m.type === "permission_request"; });
assert.equal(request.mcpServer, null);
session.pendingPermissions[request.requestId].resolve({ behavior: "deny", message: "test cleanup" });
await decisionPromise;
});
test("a bypass-permissions session still requests the SDK bypass mode explicitly", async function () {
var captures = [];
var sdk = sdkFixture(captures);
var adapter = createClaudeAdapter({ cwd: process.cwd(), loadSDK: function () { return Promise.resolve(sdk); } });
var session = { localId: 203, vendor: "claude", mode: "gui", permissionMode: "bypassPermissions", history: [], pendingAskUser: {}, pendingPermissions: {}, pendingElicitations: {} };
var sm = baseSessionManager(new Map([[203, session]]));
var bridge = createSDKBridge({ cwd: process.cwd(), sessionManager: sm, adapter: adapter, adapters: { claude: adapter }, send: function () {} });
await bridge.startQuery(session, "hello", null, null);
// Clay's own full-access callback stays in canUseTool; the SDK-level
// bypassPermissions escape hatch is intentionally not requested here.
assert.equal(captures[0].permissionMode, "default");
});
test("a literal Auto session sends the SDK's own auto mode explicitly, not omitted", async function () {
var captures = [];
var sdk = sdkFixture(captures);
var adapter = createClaudeAdapter({ cwd: process.cwd(), loadSDK: function () { return Promise.resolve(sdk); } });
var session = { localId: 204, vendor: "claude", mode: "gui", permissionMode: "auto", history: [], pendingAskUser: {}, pendingPermissions: {}, pendingElicitations: {} };
var sm = baseSessionManager(new Map([[204, session]]));
var bridge = createSDKBridge({ cwd: process.cwd(), sessionManager: sm, adapter: adapter, adapters: { claude: adapter }, send: function () {} });
await bridge.startQuery(session, "hello", null, null);
assert.equal(captures[0].permissionMode, "auto");
});
test("a Plan session sends plan mode explicitly", async function () {
var captures = [];
var sdk = sdkFixture(captures);
var adapter = createClaudeAdapter({ cwd: process.cwd(), loadSDK: function () { return Promise.resolve(sdk); } });
var session = { localId: 205, vendor: "claude", mode: "gui", permissionMode: "plan", history: [], pendingAskUser: {}, pendingPermissions: {}, pendingElicitations: {} };
var sm = baseSessionManager(new Map([[205, session]]));
var bridge = createSDKBridge({ cwd: process.cwd(), sessionManager: sm, adapter: adapter, adapters: { claude: adapter }, send: function () {} });
await bridge.startQuery(session, "hello", null, null);
assert.equal(captures[0].permissionMode, "plan");
});
test("the OS-worker path forwards every explicit permissionMode, including default, to the worker's query options", function () {
var modes = ["default", "acceptEdits", "bypassPermissions", "auto", "plan"];
for (var i = 0; i < modes.length; i++) {
var options = buildWorkerQueryOptions({}, { permissionMode: modes[i] }, "/tmp/work");
assert.equal(options.permissionMode, modes[i],
"mode '" + modes[i] + "' must cross the worker IPC boundary unchanged, including 'default' which is falsy-adjacent but a real string");
}
});
test("warmup queries stay hardcoded to the SDK's bypassPermissions escape hatch, unaffected by the explicit-default fix", function () {
var source = fs.readFileSync("lib/yoke/adapters/claude.js", "utf8");
var matches = source.match(/warmupOptions\.permissionMode = "bypassPermissions";/g) || [];
assert.ok(matches.length >= 3, "every warmup query construction site must still force bypassPermissions explicitly");
});
test("title generation stays hardcoded to explicit default, unaffected by the explicit-default fix", function () {
var source = fs.readFileSync("lib/yoke/adapters/claude.js", "utf8");
var generateTitleBlock = source.slice(source.indexOf("generateTitle: async function(messages, opts) {"));
generateTitleBlock = generateTitleBlock.slice(0, generateTitleBlock.indexOf("getSessionInfo:"));
assert.match(generateTitleBlock, /permissionMode:\s*"default",/,
"title generation already explicitly requested Ask/default before this fix and must still do so");
});