server/coder: working-tree backend changes (pre-existing)
Checkpoint of in-progress backend work present in the tree, not authored this session: auto_name, inference tool-phase/turn, secret_guard, provider-registry, plus a new agent-allowlist test (7 tests, passing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,12 +24,6 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
transport: 'native',
|
transport: 'native',
|
||||||
modelSource: 'llama-swap',
|
modelSource: 'llama-swap',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'cursor',
|
|
||||||
label: 'Cursor Agent',
|
|
||||||
transport: 'acp',
|
|
||||||
modelSource: 'probe',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'opencode',
|
name: 'opencode',
|
||||||
label: 'OpenCode',
|
label: 'OpenCode',
|
||||||
@@ -59,12 +53,6 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
transport: 'acp',
|
transport: 'acp',
|
||||||
modelSource: 'probe',
|
modelSource: 'probe',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'copilot',
|
|
||||||
label: 'GitHub Copilot',
|
|
||||||
transport: 'acp',
|
|
||||||
modelSource: 'probe',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
||||||
|
|||||||
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseAgentsMd, matchToolGlob } from '../agents.js';
|
||||||
|
import { toolJsonSchemas } from '../tools.js';
|
||||||
|
|
||||||
|
describe('agent tool allowlist', () => {
|
||||||
|
const plannerMd = `# Agents
|
||||||
|
|
||||||
|
## Planner
|
||||||
|
---
|
||||||
|
temperature: 0.6
|
||||||
|
tools: [view_file, grep, list_dir, find_files]
|
||||||
|
description: Read-only planner
|
||||||
|
---
|
||||||
|
You plan.
|
||||||
|
`;
|
||||||
|
|
||||||
|
it('parses an agent with a restricted tool allowlist', () => {
|
||||||
|
const { agents, errors } = parseAgentsMd(plannerMd);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(planner.name).toBe('Planner');
|
||||||
|
expect(planner.tools).toEqual(['view_file', 'grep', 'list_dir', 'find_files']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stream-phase filter: agent allowlist excludes tools not in the list', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
const allSchemas = toolJsonSchemas();
|
||||||
|
const filtered = allSchemas.filter((t) =>
|
||||||
|
matchToolGlob(t.function.name, planner.tools),
|
||||||
|
);
|
||||||
|
const filteredNames = filtered.map((t) => t.function.name);
|
||||||
|
expect(filteredNames).toContain('view_file');
|
||||||
|
expect(filteredNames).toContain('grep');
|
||||||
|
expect(filteredNames).not.toContain('edit_file');
|
||||||
|
expect(filteredNames).not.toContain('web_search');
|
||||||
|
expect(filteredNames).not.toContain('get_codebase_overview');
|
||||||
|
expect(filtered).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool-phase guard: rejects tool call not in agent allowlist', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(matchToolGlob('edit_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('create_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('delete_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('web_search', planner.tools)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool-phase guard: allows tool call in agent allowlist', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(matchToolGlob('view_file', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('grep', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('list_dir', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('find_files', planner.tools)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null/absent tools field defaults to all tools (no regression)', () => {
|
||||||
|
const noToolsMd = `# Agents
|
||||||
|
|
||||||
|
## Default
|
||||||
|
---
|
||||||
|
temperature: 0.7
|
||||||
|
description: Uses all tools
|
||||||
|
---
|
||||||
|
Default agent.
|
||||||
|
`;
|
||||||
|
const { agents } = parseAgentsMd(noToolsMd);
|
||||||
|
const agent = agents[0]!;
|
||||||
|
const allSchemas = toolJsonSchemas();
|
||||||
|
const filtered = allSchemas.filter((t) =>
|
||||||
|
matchToolGlob(t.function.name, agent.tools),
|
||||||
|
);
|
||||||
|
expect(filtered.length).toBe(allSchemas.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builder agent: write tools filtered out when not in ALL_TOOLS (BooChat context)', () => {
|
||||||
|
const builderMd = `# Agents
|
||||||
|
|
||||||
|
## Builder
|
||||||
|
---
|
||||||
|
temperature: 0.6
|
||||||
|
tools: [view_file, grep, list_dir, find_files, edit_file, create_file, delete_file, apply_pending, rewind]
|
||||||
|
description: Read and write tools
|
||||||
|
---
|
||||||
|
You build.
|
||||||
|
`;
|
||||||
|
const { agents } = parseAgentsMd(builderMd);
|
||||||
|
const builder = agents[0]!;
|
||||||
|
expect(matchToolGlob('view_file', builder.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('grep', builder.tools)).toBe(true);
|
||||||
|
// Write tools not in server's ALL_TOOLS are silently filtered during parsing.
|
||||||
|
// In BooCoder context (where ALL_TOOLS includes write tools), they'd be retained.
|
||||||
|
expect(builder.tools).not.toContain('edit_file');
|
||||||
|
expect(builder.tools).not.toContain('create_file');
|
||||||
|
expect(matchToolGlob('web_search', builder.tools)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matchToolGlob rejects hallucinated tool against exact allowlist', () => {
|
||||||
|
const allowlist = ['view_file', 'grep', 'list_dir'];
|
||||||
|
expect(matchToolGlob('edit_file', allowlist)).toBe(false);
|
||||||
|
expect(matchToolGlob('rm_rf', allowlist)).toBe(false);
|
||||||
|
expect(matchToolGlob('view_file_extended', allowlist)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,22 +45,26 @@ export async function maybeAutoNameChat(
|
|||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
if (chat.name !== null && chat.name !== '') return;
|
if (chat.name !== null && chat.name !== '') return;
|
||||||
|
|
||||||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
const firstMsgs = await ctx.sql<{ role: string; content: string }[]>`
|
||||||
SELECT content FROM messages
|
SELECT role, content FROM messages
|
||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId}
|
||||||
AND role = 'assistant'
|
AND role IN ('user', 'assistant')
|
||||||
AND status = 'complete'
|
AND status IN ('complete', 'ok')
|
||||||
AND content <> ''
|
AND content <> ''
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 1
|
LIMIT 2
|
||||||
`;
|
`;
|
||||||
if (!assistantMsg[0]) return;
|
const userMsg = firstMsgs.find(m => m.role === 'user');
|
||||||
|
const assistantMsg = firstMsgs.find(m => m.role === 'assistant');
|
||||||
|
if (!assistantMsg) return;
|
||||||
|
|
||||||
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
let namingInput = '';
|
||||||
|
if (userMsg) namingInput += `User: ${userMsg.content.slice(0, 1000)}\n\n`;
|
||||||
|
namingInput += `Assistant: ${assistantMsg.content.slice(0, 1000)}`;
|
||||||
|
|
||||||
const raw = await taskModelCompletion({
|
const raw = await taskModelCompletion({
|
||||||
system: NAMING_SYSTEM_PROMPT,
|
system: NAMING_SYSTEM_PROMPT,
|
||||||
user: assistantText,
|
user: namingInput,
|
||||||
maxTokens: 30,
|
maxTokens: 30,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Session, ToolCall } from '../../types/api.js';
|
import type { Agent, Session, ToolCall } from '../../types/api.js';
|
||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { PathScopeError } from '../path_guard.js';
|
import { PathScopeError } from '../path_guard.js';
|
||||||
import { TOOLS_BY_NAME } from '../tools.js';
|
import { TOOLS_BY_NAME } from '../tools.js';
|
||||||
|
import { matchToolGlob } from '../agents.js';
|
||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||||
// v1.13.16: richer unknown-tool error so the model can self-correct when it
|
// v1.13.16: richer unknown-tool error so the model can self-correct when it
|
||||||
@@ -98,7 +99,8 @@ export async function executeToolPhase(
|
|||||||
result: StreamResult,
|
result: StreamResult,
|
||||||
startedAt: string | null,
|
startedAt: string | null,
|
||||||
session: Session,
|
session: Session,
|
||||||
projectRoot: string
|
projectRoot: string,
|
||||||
|
agent?: Agent | null,
|
||||||
): Promise<ToolPhaseResult> {
|
): Promise<ToolPhaseResult> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const content = stripToolMarkup(result.content, { final: true });
|
const content = stripToolMarkup(result.content, { final: true });
|
||||||
@@ -262,6 +264,31 @@ export async function executeToolPhase(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (agent && !matchToolGlob(tc.name, agent.tools)) {
|
||||||
|
const stored = {
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
output: null,
|
||||||
|
truncated: false,
|
||||||
|
error: `tool '${tc.name}' is not allowed for agent '${agent.name}'`,
|
||||||
|
};
|
||||||
|
await insertParts(
|
||||||
|
ctx.sql,
|
||||||
|
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||||
|
...p,
|
||||||
|
message_id: toolMessageId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: toolMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
output: stored.output,
|
||||||
|
truncated: false,
|
||||||
|
error: stored.error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
||||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ export async function runAssistantTurn(
|
|||||||
// ---- tool phase ----
|
// ---- tool phase ----
|
||||||
let toolPhaseResult: ToolPhaseResult;
|
let toolPhaseResult: ToolPhaseResult;
|
||||||
try {
|
try {
|
||||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Tool phase errors are unexpected (individual tool failures are
|
// Tool phase errors are unexpected (individual tool failures are
|
||||||
// caught inside executeToolPhase). Log and break.
|
// caught inside executeToolPhase). Log and break.
|
||||||
|
|||||||
@@ -163,6 +163,13 @@ const COMPILED: ReadonlyArray<CompiledPattern> = DEFAULT_SECURITY_IGNORE_FILETYP
|
|||||||
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
||||||
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
||||||
// paths match the same patterns. Empty or root-only paths return false.
|
// paths match the same patterns. Empty or root-only paths return false.
|
||||||
|
const SAFE_PATTERNS: ReadonlySet<string> = new Set([
|
||||||
|
'.env.example',
|
||||||
|
'.env.sample',
|
||||||
|
'.env.template',
|
||||||
|
'.env.defaults',
|
||||||
|
]);
|
||||||
|
|
||||||
export function isSecretPath(relPath: string): boolean {
|
export function isSecretPath(relPath: string): boolean {
|
||||||
if (!relPath) return false;
|
if (!relPath) return false;
|
||||||
const normalized = relPath.replace(/\\/g, '/');
|
const normalized = relPath.replace(/\\/g, '/');
|
||||||
@@ -170,6 +177,8 @@ export function isSecretPath(relPath: string): boolean {
|
|||||||
if (segments.length === 0) return false;
|
if (segments.length === 0) return false;
|
||||||
const base = segments[segments.length - 1]!;
|
const base = segments[segments.length - 1]!;
|
||||||
|
|
||||||
|
if (SAFE_PATTERNS.has(base.toLowerCase())) return false;
|
||||||
|
|
||||||
for (const compiled of COMPILED) {
|
for (const compiled of COMPILED) {
|
||||||
if (compiled.mode === 'basename') {
|
if (compiled.mode === 'basename') {
|
||||||
if (compiled.regex.test(base)) return true;
|
if (compiled.regex.test(base)) return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user