mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-22 02:53:26 -05:00
285 lines
9.2 KiB
TypeScript
285 lines
9.2 KiB
TypeScript
/**
|
|
* Tests for per-agent scoped tool credentials (issue #19).
|
|
*
|
|
* `AgentConfig.credentials` is a per-agent secret bag threaded into
|
|
* `ToolUseContext.credentials`, so tool code reads `context.credentials?.KEY`
|
|
* instead of closing a single shared secret over every tool. The security
|
|
* property under test: each agent holds only the bag assigned to it — one
|
|
* agent's tool cannot read another agent's credentials — the bag is absent by
|
|
* default, survives the orchestrator's per-agent config spread, and never
|
|
* leaks into emitted traces.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import { z } from 'zod'
|
|
import { Agent } from '../src/agent/agent.js'
|
|
import { OpenMultiAgent } from '../src/orchestrator/orchestrator.js'
|
|
import { ToolRegistry, defineTool } from '../src/tool/framework.js'
|
|
import { ToolExecutor } from '../src/tool/executor.js'
|
|
import { isSensitiveName } from '../src/utils/redaction.js'
|
|
import type {
|
|
AgentConfig,
|
|
LLMAdapter,
|
|
LLMChatOptions,
|
|
LLMMessage,
|
|
LLMResponse,
|
|
ToolUseContext,
|
|
} from '../src/types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers (mirror tests/default-deny-tools.test.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function toolUse(name: string, input: Record<string, unknown>): LLMResponse {
|
|
return {
|
|
id: `resp-${name}`,
|
|
content: [{ type: 'tool_use', id: `tu-${name}`, name, input }],
|
|
model: 'mock-model',
|
|
stop_reason: 'tool_use',
|
|
usage: { input_tokens: 5, output_tokens: 5 },
|
|
}
|
|
}
|
|
|
|
function text(t: string): LLMResponse {
|
|
return {
|
|
id: 'resp-text',
|
|
content: [{ type: 'text', text: t }],
|
|
model: 'mock-model',
|
|
stop_reason: 'end_turn',
|
|
usage: { input_tokens: 5, output_tokens: 5 },
|
|
}
|
|
}
|
|
|
|
/** Replays `steps` one per turn, repeating the last once exhausted. */
|
|
function scriptedAdapter(steps: LLMResponse[]): LLMAdapter {
|
|
let i = 0
|
|
return {
|
|
name: 'mock',
|
|
async chat(_messages: LLMMessage[], _options: LLMChatOptions): Promise<LLMResponse> {
|
|
return steps[Math.min(i++, steps.length - 1)]!
|
|
},
|
|
async *stream() {
|
|
/* unused */
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A custom tool that records the `ToolUseContext.credentials` it was handed.
|
|
* Registration is the grant (custom tools bypass preset/allowlist), so the
|
|
* agent can call it without a `toolPreset`.
|
|
*/
|
|
function credentialSpy(name = 'read_secret'): {
|
|
tool: ReturnType<typeof defineTool>
|
|
seen: () => ToolUseContext['credentials']
|
|
callCount: () => number
|
|
} {
|
|
let seen: ToolUseContext['credentials']
|
|
let calls = 0
|
|
const tool = defineTool({
|
|
name,
|
|
description: 'Records the credentials visible to this agent.',
|
|
inputSchema: z.object({}),
|
|
execute: async (_input, context) => {
|
|
calls++
|
|
seen = context.credentials
|
|
return { data: 'ok', isError: false }
|
|
},
|
|
})
|
|
return { tool, seen: () => seen, callCount: () => calls }
|
|
}
|
|
|
|
// A short goal with no coordination signals → runTeam short-circuit (single agent).
|
|
const SIMPLE_GOAL = 'Briefly explain what a hash map is.'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Standalone Agent path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('per-agent credentials: standalone Agent', () => {
|
|
it('threads the agent credentials bag into ToolUseContext', async () => {
|
|
const { tool, seen } = credentialSpy()
|
|
const registry = new ToolRegistry()
|
|
registry.register(tool, { runtimeAdded: true })
|
|
|
|
const agent = new Agent(
|
|
{
|
|
name: 'solo',
|
|
model: 'mock-model',
|
|
adapter: scriptedAdapter([toolUse('read_secret', {}), text('done')]),
|
|
credentials: { SEARCH_API_KEY: 'sk-search-123' },
|
|
} satisfies AgentConfig,
|
|
registry,
|
|
new ToolExecutor(registry),
|
|
)
|
|
|
|
await agent.run('Use the tool.')
|
|
|
|
expect(seen()).toEqual({ SEARCH_API_KEY: 'sk-search-123' })
|
|
})
|
|
|
|
it('leaves context.credentials undefined when no bag is set', async () => {
|
|
const { tool, seen, callCount } = credentialSpy()
|
|
const registry = new ToolRegistry()
|
|
registry.register(tool, { runtimeAdded: true })
|
|
|
|
const agent = new Agent(
|
|
{
|
|
name: 'solo',
|
|
model: 'mock-model',
|
|
adapter: scriptedAdapter([toolUse('read_secret', {}), text('done')]),
|
|
} satisfies AgentConfig,
|
|
registry,
|
|
new ToolExecutor(registry),
|
|
)
|
|
|
|
await agent.run('Use the tool.')
|
|
|
|
// The tool ran, but saw no credentials.
|
|
expect(callCount()).toBe(1)
|
|
expect(seen()).toBeUndefined()
|
|
})
|
|
|
|
it("isolates one agent's credentials from another's", async () => {
|
|
const a = credentialSpy('read_secret')
|
|
const regA = new ToolRegistry()
|
|
regA.register(a.tool, { runtimeAdded: true })
|
|
const agentA = new Agent(
|
|
{
|
|
name: 'a',
|
|
model: 'mock-model',
|
|
adapter: scriptedAdapter([toolUse('read_secret', {}), text('done')]),
|
|
credentials: { TOKEN: 'secret-A' },
|
|
} satisfies AgentConfig,
|
|
regA,
|
|
new ToolExecutor(regA),
|
|
)
|
|
|
|
const b = credentialSpy('read_secret')
|
|
const regB = new ToolRegistry()
|
|
regB.register(b.tool, { runtimeAdded: true })
|
|
const agentB = new Agent(
|
|
{
|
|
name: 'b',
|
|
model: 'mock-model',
|
|
adapter: scriptedAdapter([toolUse('read_secret', {}), text('done')]),
|
|
credentials: { TOKEN: 'secret-B' },
|
|
} satisfies AgentConfig,
|
|
regB,
|
|
new ToolExecutor(regB),
|
|
)
|
|
|
|
await agentA.run('go')
|
|
await agentB.run('go')
|
|
|
|
expect(a.seen()).toEqual({ TOKEN: 'secret-A' })
|
|
expect(b.seen()).toEqual({ TOKEN: 'secret-B' })
|
|
// Neither tool ever observed the other agent's secret.
|
|
expect(JSON.stringify(a.seen())).not.toContain('secret-B')
|
|
expect(JSON.stringify(b.seen())).not.toContain('secret-A')
|
|
})
|
|
|
|
it('does not leak credential values into emitted traces', async () => {
|
|
const { tool } = credentialSpy()
|
|
const registry = new ToolRegistry()
|
|
registry.register(tool, { runtimeAdded: true })
|
|
const traces: unknown[] = []
|
|
|
|
const agent = new Agent(
|
|
{
|
|
name: 'solo',
|
|
model: 'mock-model',
|
|
adapter: scriptedAdapter([toolUse('read_secret', {}), text('done')]),
|
|
credentials: { TOKEN: 'top-secret-value' },
|
|
} satisfies AgentConfig,
|
|
registry,
|
|
new ToolExecutor(registry),
|
|
)
|
|
|
|
await agent.run('Use the tool.', {
|
|
onTrace: (e) => {
|
|
traces.push(e)
|
|
},
|
|
runId: 'run-creds',
|
|
})
|
|
|
|
// Traces were actually captured, so the negative assertion is meaningful.
|
|
expect(traces.length).toBeGreaterThan(0)
|
|
// The credential value never appears anywhere in the trace stream.
|
|
expect(JSON.stringify(traces)).not.toContain('top-secret-value')
|
|
})
|
|
|
|
it("relies on 'credentials' being a redaction-sensitive key", () => {
|
|
// Defense in depth: the field is documented as auto-redacted. If a bag is
|
|
// ever serialized into a payload passing through redactSensitiveObject, the
|
|
// key name must trigger redaction. Guards the field name against silent
|
|
// divergence from the redaction pattern.
|
|
expect(isSensitiveName('credentials')).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orchestrator path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('per-agent credentials: orchestrator', () => {
|
|
it('survives the runTeam short-circuit config spread', async () => {
|
|
const { tool, seen } = credentialSpy()
|
|
const oma = new OpenMultiAgent({
|
|
executionRouting: { strategy: 'deterministic' },
|
|
})
|
|
const team = oma.createTeam('t', {
|
|
name: 't',
|
|
agents: [
|
|
{
|
|
name: 'solo',
|
|
model: 'mock-model',
|
|
adapter: scriptedAdapter([toolUse('read_secret', {}), text('finished')]),
|
|
credentials: { CMS_TOKEN: 'cms-xyz' },
|
|
customTools: [tool],
|
|
},
|
|
],
|
|
})
|
|
|
|
await oma.runTeam(team, SIMPLE_GOAL)
|
|
|
|
expect(seen()).toEqual({ CMS_TOKEN: 'cms-xyz' })
|
|
})
|
|
|
|
it("gives a delegated subagent its own credentials, not the delegator's", async () => {
|
|
// The #19 headline: a compromised/misbehaving subagent must not inherit the
|
|
// delegator's access. Agent `a` delegates to `b`; `b`'s tool must see only
|
|
// `b`'s bag — never `a`'s secret.
|
|
const b = credentialSpy('read_secret')
|
|
const oma = new OpenMultiAgent({})
|
|
const team = oma.createTeam('t', {
|
|
name: 't',
|
|
agents: [
|
|
{
|
|
name: 'a',
|
|
model: 'mock-model',
|
|
adapter: scriptedAdapter([
|
|
toolUse('delegate_to_agent', { target_agent: 'b', prompt: 'read the secret' }),
|
|
text('delegated'),
|
|
]),
|
|
tools: ['delegate_to_agent'],
|
|
credentials: { TOKEN: 'secret-A' },
|
|
},
|
|
{
|
|
name: 'b',
|
|
model: 'mock-model',
|
|
adapter: scriptedAdapter([toolUse('read_secret', {}), text('done')]),
|
|
customTools: [b.tool],
|
|
credentials: { TOKEN: 'secret-B' },
|
|
},
|
|
],
|
|
})
|
|
|
|
await oma.runTasks(team, [{ title: 'Task A', description: 'delegate to b', assignee: 'a' }])
|
|
|
|
// `b` ran via delegation and saw exactly its own bag — never the delegator's.
|
|
expect(b.seen()).toEqual({ TOKEN: 'secret-B' })
|
|
expect(JSON.stringify(b.seen())).not.toContain('secret-A')
|
|
})
|
|
})
|