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
47 changes: 47 additions & 0 deletions lib/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
//
// This implementation lives only on our fork; we are not pushing it upstream.

var fs = require("fs");
var path = require("path");
var os = require("os");

Expand Down Expand Up @@ -79,6 +80,51 @@ function slugifyAgentName(name) {
.slice(0, 64);
}

// lr-4c90 — Read the tools list for a named agent directly from its on-disk
// frontmatter. The SDK AgentInfo surface (supportedAgents()) does not expose
// the tools field — only name, description, and model are returned. Reading
// the file directly is the only synchronous path for belt-and-suspenders
// enforcement in the query setup path.
//
// agentName: the agent identity string (e.g. "agentic-director").
// Returns: string[] of tool names if the tools field is a valid JSON array,
// null if the file is absent, the frontmatter is missing, or the
// tools field cannot be parsed.
//
// Failures are silent (logged at debug level) — callers treat null as "no
// restriction from this source" and fall through to whatever the SDK applies
// via claudeOpts.agent.
function readAgentToolsFromFile(agentName) {
if (!agentName || typeof agentName !== "string") return null;
var slug = slugifyAgentName(agentName);
if (!slug) return null;
var filePath = path.join(AGENTS_SOURCE_DIR, slug + ".md");
var raw;
try {
raw = fs.readFileSync(filePath, "utf8");
} catch (e) {
// File not found is expected for agents with no on-disk definition.
return null;
}
var parsed = parseFrontmatter(raw);
if (!parsed || !parsed.meta) return null;
var toolsVal = parsed.meta.tools;
if (!toolsVal || typeof toolsVal !== "string") return null;
var trimmed = toolsVal.trim();
if (trimmed.charAt(0) !== "[") return null; // not a JSON array — skip
try {
var arr = JSON.parse(trimmed);
if (!Array.isArray(arr)) return null;
var result = [];
for (var i = 0; i < arr.length; i++) {
if (typeof arr[i] === "string" && arr[i]) result.push(arr[i]);
}
return result.length > 0 ? result : null;
} catch (e) {
return null;
}
}

// --- SDK-backed discovery cache ---

// Module-level cache. Populated by refresh(); read by getAll().
Expand Down Expand Up @@ -182,6 +228,7 @@ module.exports = {
PLUGINS_CACHE_DIR: PLUGINS_CACHE_DIR,
parseFrontmatter: parseFrontmatter,
slugifyAgentName: slugifyAgentName,
readAgentToolsFromFile: readAgentToolsFromFile,
// New SDK-backed surface:
refresh: refresh,
getAll: getAll,
Expand Down
12 changes: 12 additions & 0 deletions lib/sdk-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ var { getCodexConfig } = require("./codex-defaults");
var { splitShellSegments, attachSkillDiscovery } = require("./sdk-skill-discovery");
var { createMessageQueue } = require("./sdk-message-queue");
var { attachMessageProcessor } = require("./sdk-message-processor");
var { readAgentToolsFromFile } = require("./agents");

// Extract serializable tool descriptors from MCP server instances.
// Used for IPC to worker processes (McpSdkServerConfigWithInstance is not serializable).
Expand Down Expand Up @@ -1193,8 +1194,19 @@ function createSDKBridge(opts) {
// hides the Agent Chat entry point for Codex projects; this guard is
// belt-and-suspenders for sessions that carry agentName from a prior
// vendor switch or a direct ws message.
//
// lr-4c90 — belt-and-suspenders tool enforcement: the SDK's `agent` option
// should apply tool restrictions, but only does so when the agent file uses
// JSON array syntax for the tools field. We read the tools list directly
// from disk and set claudeOpts.tools explicitly so restriction is enforced
// even if the SDK's parsing path has any quirks.
if (session.agentName && sessionAdapter.vendor !== 'codex') {
claudeOpts.agent = session.agentName;
var agentTools = readAgentToolsFromFile(session.agentName);
if (agentTools) {
claudeOpts.tools = agentTools;
console.log("[sdk-bridge] agent tools enforced for " + session.agentName + ": " + agentTools.join(", "));
}
}

// Per-loop settings override global defaults when present
Expand Down
244 changes: 244 additions & 0 deletions test/agents.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
// lr-4c90 — regression tests for readAgentToolsFromFile
//
// Verifies that the helper correctly reads the tools frontmatter field from
// agent definition files and returns a string[] or null. This is the
// enforcement path for belt-and-suspenders tool restriction in sdk-bridge.js.

var test = require("node:test");
var assert = require("node:assert");
var fs = require("fs");
var path = require("path");
var os = require("os");

// We need to control AGENTS_SOURCE_DIR. The module reads it at load time from
// os.homedir(), so we override it after requiring — the function uses the
// module-level var, not the export. The cleanest approach for a unit test is
// to write real temp files and point AGENTS_SOURCE_DIR at the temp dir via a
// wrapper. Since readAgentToolsFromFile uses path.join(AGENTS_SOURCE_DIR, ...),
// and AGENTS_SOURCE_DIR is a module-level var (not a const), we work around
// this by writing temp files under the actual AGENTS_SOURCE_DIR path (which
// exists on this machine) and using unlikely agent names that won't collide.
//
// Alternatively, we test the function via a thin wrapper that accepts a dir.
// The current implementation does not accept a dir override — we test by
// writing real temp files using a uniquely-named test agent.

var agentsModule = require("../lib/agents");
var { readAgentToolsFromFile, parseFrontmatter, AGENTS_SOURCE_DIR } = agentsModule;

// ============================================================
// parseFrontmatter unit tests
// ============================================================

test("parseFrontmatter: parses JSON array tools field as raw string", function () {
var raw = [
"---",
'name: test-agent',
'tools: ["Read", "Grep", "Bash"]',
"---",
"Agent body.",
].join("\n");
var result = parseFrontmatter(raw);
assert.ok(result, "should return a result");
assert.strictEqual(result.meta.tools, '["Read", "Grep", "Bash"]',
"tools field value should be the raw JSON string (parseFrontmatter does not JSON-parse)");
assert.strictEqual(result.body, "Agent body.");
});

test("parseFrontmatter: parses comma-string tools field as raw string", function () {
var raw = [
"---",
"tools: Read, Grep, Bash",
"---",
"body",
].join("\n");
var result = parseFrontmatter(raw);
assert.strictEqual(result.meta.tools, "Read, Grep, Bash");
});

test("parseFrontmatter: returns empty meta for no frontmatter", function () {
var result = parseFrontmatter("Just a body, no frontmatter.");
assert.deepStrictEqual(result.meta, {});
assert.strictEqual(result.body, "Just a body, no frontmatter.");
});

// ============================================================
// readAgentToolsFromFile — null-guard and parse cases
// ============================================================

test("readAgentToolsFromFile: returns null for null agentName", function () {
assert.strictEqual(readAgentToolsFromFile(null), null);
});

test("readAgentToolsFromFile: returns null for empty agentName", function () {
assert.strictEqual(readAgentToolsFromFile(""), null);
});

test("readAgentToolsFromFile: returns null for non-string agentName", function () {
assert.strictEqual(readAgentToolsFromFile(42), null);
assert.strictEqual(readAgentToolsFromFile(true), null);
});

test("readAgentToolsFromFile: returns null for unknown agent (file not found)", function () {
// Agent name unlikely to exist on disk.
var result = readAgentToolsFromFile("zzz-nonexistent-agent-lr-4c90");
assert.strictEqual(result, null);
});

// Write temp agent files into AGENTS_SOURCE_DIR (or os.tmpdir if that dir
// doesn't exist — we check for each test). We use a unique prefix to avoid
// colliding with real agents.
var TEMP_AGENT_PREFIX = "zzz-test-lr4c90-";

function writeTempAgent(slug, content) {
var filePath = path.join(AGENTS_SOURCE_DIR, slug + ".md");
fs.writeFileSync(filePath, content, "utf8");
return filePath;
}

function removeTempAgent(slug) {
var filePath = path.join(AGENTS_SOURCE_DIR, slug + ".md");
try { fs.unlinkSync(filePath); } catch (e) { /* best-effort */ }
}

var canWriteAgentsDir = false;
try {
fs.accessSync(AGENTS_SOURCE_DIR, fs.constants.W_OK);
canWriteAgentsDir = true;
} catch (e) {
canWriteAgentsDir = false;
}

// Helper: skip with a TODO message if we can't write to the agents dir.
function maybeTest(name, fn) {
if (canWriteAgentsDir) {
test(name, fn);
} else {
test(name + " [SKIP — AGENTS_SOURCE_DIR not writable]", function () {
// Not a failure — this environment doesn't have a writable agents dir.
});
}
}

maybeTest("readAgentToolsFromFile: returns string[] for valid JSON array tools field", function () {
var slug = TEMP_AGENT_PREFIX + "valid";
var content = [
"---",
"name: " + slug,
'tools: ["Read", "Grep", "Bash"]',
"---",
"Test agent body.",
].join("\n");
writeTempAgent(slug, content);
try {
var result = readAgentToolsFromFile(slug);
assert.ok(Array.isArray(result), "should return an array");
assert.deepStrictEqual(result, ["Read", "Grep", "Bash"]);
} finally {
removeTempAgent(slug);
}
});

maybeTest("readAgentToolsFromFile: returns null for comma-string tools field (legacy format)", function () {
var slug = TEMP_AGENT_PREFIX + "comma-string";
var content = [
"---",
"name: " + slug,
"tools: Read, Grep, Bash",
"---",
"Test agent body.",
].join("\n");
writeTempAgent(slug, content);
try {
var result = readAgentToolsFromFile(slug);
assert.strictEqual(result, null,
"comma-string format must not be parsed — would silently apply no restriction if used");
} finally {
removeTempAgent(slug);
}
});

maybeTest("readAgentToolsFromFile: returns null for agent file with no tools field", function () {
var slug = TEMP_AGENT_PREFIX + "no-tools";
var content = [
"---",
"name: " + slug,
"description: An agent with no declared tools.",
"---",
"Body.",
].join("\n");
writeTempAgent(slug, content);
try {
var result = readAgentToolsFromFile(slug);
assert.strictEqual(result, null);
} finally {
removeTempAgent(slug);
}
});

maybeTest("readAgentToolsFromFile: returns null for malformed JSON array", function () {
var slug = TEMP_AGENT_PREFIX + "bad-json";
var content = [
"---",
"name: " + slug,
'tools: ["Read", "Grep"',
"---",
"Body.",
].join("\n");
writeTempAgent(slug, content);
try {
var result = readAgentToolsFromFile(slug);
assert.strictEqual(result, null, "malformed JSON must not throw and must return null");
} finally {
removeTempAgent(slug);
}
});

maybeTest("readAgentToolsFromFile: returns null for empty JSON array", function () {
var slug = TEMP_AGENT_PREFIX + "empty-array";
var content = [
"---",
"name: " + slug,
"tools: []",
"---",
"Body.",
].join("\n");
writeTempAgent(slug, content);
try {
var result = readAgentToolsFromFile(slug);
// An empty tools array means no tools — caller should leave enforcement
// to the SDK's own agent option rather than restricting to an empty set.
assert.strictEqual(result, null, "empty array should return null (caller leaves restriction to SDK)");
} finally {
removeTempAgent(slug);
}
});

maybeTest("readAgentToolsFromFile: agent name with spaces/caps is slugified correctly", function () {
// Agent names with uppercase letters get slugified; the file must match.
var slug = TEMP_AGENT_PREFIX + "slug-test";
var content = [
"---",
'tools: ["Read"]',
"---",
"Body.",
].join("\n");
writeTempAgent(slug, content);
try {
// The agent name "ZZZ-Test-Lr4c90-SLUG-Test" should slugify to slug.
var result = readAgentToolsFromFile("ZZZ-Test-Lr4c90-SLUG-Test");
assert.ok(Array.isArray(result), "slugified agent name should find file");
assert.deepStrictEqual(result, ["Read"]);
} finally {
removeTempAgent(slug);
}
});

// ============================================================
// Smoke: sdk-bridge loads without error (requires function is exported)
// ============================================================

test("agents module exports readAgentToolsFromFile", function () {
assert.strictEqual(typeof readAgentToolsFromFile, "function",
"readAgentToolsFromFile must be exported from lib/agents.js");
});
Loading