diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 87fc21f..000841b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Janee will be documented in this file. ## [Unreleased] +### Added + +- **Per-capability `access` override** — Capabilities can now set `access: open` or `access: restricted` to override the global `defaultAccess` policy. Useful for mixed environments where some capabilities (e.g. SerpAPI) should be open to all agents while others (e.g. Stripe) are locked to specific agents. Configurable via `janee cap add --access open` / `janee cap edit --access restricted` / `janee cap edit --clear-access`. Surfaced in `explain_access` traces and `cap list` output. + ### Changed - **Refactor: Extract shared CLI utilities** — Common helpers (`cliError`, `requireConfig`, `resolveEnvVar`, `parseEnvMap`, `handleCommandError`) moved to `src/cli/cli-utils.ts`. Removes ~200 lines of duplicated code across 14 CLI command files. Error output format is now consistent (`{ ok: false, error }`) across all commands. diff --git a/src/cli/commands/capability.ts b/src/cli/commands/capability.ts index 30beb72..04551d9 100644 --- a/src/cli/commands/capability.ts +++ b/src/cli/commands/capability.ts @@ -24,6 +24,7 @@ export async function capabilityListCommand(options: { json?: boolean } = {}): P allowRules: cap.rules?.allow || [], denyRules: cap.rules?.deny || [], mode: cap.mode, + access: cap.access, allowedAgents: cap.allowedAgents, allowCommands: cap.allowCommands, env: cap.env, @@ -59,6 +60,7 @@ export async function capabilityListCommand(options: { json?: boolean } = {}): P if (cap.autoApprove !== undefined) console.log(` Auto-approve: ${cap.autoApprove}`); if (cap.requiresReason !== undefined) console.log(` Requires reason: ${cap.requiresReason}`); if (rules) console.log(` Rules:${rules}`); + if (cap.access) console.log(` Access: ${cap.access}`); if (cap.allowedAgents?.length) console.log(` Allowed agents: ${cap.allowedAgents.join(', ')}`); if (cap.allowCommands?.length) console.log(` Allow commands: ${cap.allowCommands.join(', ')}`); if (cap.env) console.log(` Env: ${Object.entries(cap.env).map(([k,v]) => `${k}=${v}`).join(', ')}`); @@ -81,6 +83,8 @@ interface CapAddEditOptions { clearRules?: boolean; allowedAgents?: string[]; clearAgents?: boolean; + access?: string; + clearAccess?: boolean; mode?: string; allowCommands?: string[]; envMap?: string[]; @@ -96,6 +100,15 @@ function applyCapabilityOptions(cap: CapabilityConfig, options: CapAddEditOption if (options.clearAgents) { delete cap.allowedAgents; } + if (options.access) { + if (options.access !== 'open' && options.access !== 'restricted') { + throw new Error(`Invalid access policy "${options.access}" — must be "open" or "restricted"`); + } + cap.access = options.access; + } + if (options.clearAccess) { + delete cap.access; + } if (options.mode) { if (options.mode !== 'proxy' && options.mode !== 'exec') { throw new Error(`Invalid mode "${options.mode}" — must be "proxy" or "exec"`); @@ -169,6 +182,7 @@ export async function capabilityAddCommand( console.log(` Service: ${capability.service}`); console.log(` TTL: ${capability.ttl}`); if (capability.mode) console.log(` Mode: ${capability.mode}`); + if (capability.access) console.log(` Access: ${capability.access}`); if (capability.allowedAgents) console.log(` Allowed agents: ${capability.allowedAgents.join(', ')}`); } diff --git a/src/cli/commands/serve-mcp.ts b/src/cli/commands/serve-mcp.ts index e5aa633..3475974 100644 --- a/src/cli/commands/serve-mcp.ts +++ b/src/cli/commands/serve-mcp.ts @@ -54,6 +54,7 @@ function loadConfigForMCP(): ReloadResult { requiresReason: cap.requiresReason, rules: cap.rules, allowedAgents: cap.allowedAgents, + access: cap.access, // Exec mode fields (RFC 0001) mode: cap.mode || 'proxy', allowCommands: cap.allowCommands, diff --git a/src/cli/config-yaml.ts b/src/cli/config-yaml.ts index 61b1d07..6fdc06d 100644 --- a/src/cli/config-yaml.ts +++ b/src/cli/config-yaml.ts @@ -66,6 +66,7 @@ export interface CapabilityConfig { deny?: string[]; }; allowedAgents?: string[]; + access?: 'open' | 'restricted'; mode?: 'proxy' | 'exec'; allowCommands?: string[]; env?: Record; diff --git a/src/cli/index.ts b/src/cli/index.ts index 2d8ce66..60aec17 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -264,6 +264,7 @@ cap .option('--allow ', 'Allow rules (e.g., "GET /v1/*")') .option('--deny ', 'Deny rules (e.g., "DELETE *")') .option('--allowed-agents ', 'Restrict to specific agent IDs') + .option('--access ', 'Per-capability access policy: open or restricted') .option('--mode ', 'Execution mode: proxy or exec') .option('--allow-commands ', 'Allowed executables for exec mode') .option('--env-map ', 'Env var mappings (KEY=value or KEY={{credential}})') @@ -285,6 +286,8 @@ cap .option('--clear-rules', 'Clear all rules') .option('--allowed-agents ', 'Restrict to specific agent IDs') .option('--clear-agents', 'Remove all agent restrictions') + .option('--access ', 'Per-capability access policy: open, restricted, or inherit') + .option('--clear-access', 'Remove per-capability access override (inherit from global)') .option('--mode ', 'Execution mode: proxy or exec') .option('--allow-commands ', 'Allowed executables for exec mode') .option('--env-map ', 'Env var mappings (KEY=value or KEY={{credential}})') diff --git a/src/core/mcp-server-handlers.test.ts b/src/core/mcp-server-handlers.test.ts index 1c600d0..dbb9b84 100644 --- a/src/core/mcp-server-handlers.test.ts +++ b/src/core/mcp-server-handlers.test.ts @@ -734,6 +734,136 @@ describe('MCP Handler Integration — Agent-Scoped Credentials', () => { }); }); + describe('per-capability access override', () => { + it('should allow access to cap with access:"open" even when global defaultAccess is restricted', async () => { + const services = new Map([ + ['svc', { baseUrl: 'https://api.test.com', auth: { type: 'bearer', key: 'k' } }], + ]); + const capabilities: Capability[] = [ + { name: 'open-cap', service: 'svc', ttl: '1h', autoApprove: true, access: 'open' }, + { name: 'default-cap', service: 'svc', ttl: '1h', autoApprove: true }, + ]; + + const { client } = await createTestPair({ + clientName: 'any-agent', services, capabilities, defaultAccess: 'restricted', + }); + + const openResult = await client.callTool({ + name: 'execute', arguments: { capability: 'open-cap', method: 'GET', path: '/data' } + }); + expect(openResult.isError).toBeFalsy(); + + const defaultResult = await client.callTool({ + name: 'execute', arguments: { capability: 'default-cap', method: 'GET', path: '/data' } + }); + expect(defaultResult.isError).toBe(true); + }); + + it('should deny access to cap with access:"restricted" even when global defaultAccess is open', async () => { + const services = new Map([ + ['svc', { baseUrl: 'https://api.test.com', auth: { type: 'bearer', key: 'k' } }], + ]); + const capabilities: Capability[] = [ + { name: 'locked-cap', service: 'svc', ttl: '1h', autoApprove: true, access: 'restricted' }, + { name: 'normal-cap', service: 'svc', ttl: '1h', autoApprove: true }, + ]; + + const { client } = await createTestPair({ + clientName: 'any-agent', services, capabilities, defaultAccess: 'open', + }); + + const lockedResult = await client.callTool({ + name: 'execute', arguments: { capability: 'locked-cap', method: 'GET', path: '/data' } + }); + expect(lockedResult.isError).toBe(true); + const parsed = extractJSON(lockedResult); + expect(parsed.denial.reasonCode).toBe('DEFAULT_ACCESS_RESTRICTED'); + + const normalResult = await client.callTool({ + name: 'execute', arguments: { capability: 'normal-cap', method: 'GET', path: '/data' } + }); + expect(normalResult.isError).toBeFalsy(); + }); + + it('should still respect allowedAgents on a restricted capability', async () => { + const services = new Map([ + ['svc', { baseUrl: 'https://api.test.com', auth: { type: 'bearer', key: 'k' } }], + ]); + const capabilities: Capability[] = [{ + name: 'stripe-cap', service: 'svc', ttl: '1h', autoApprove: true, + access: 'restricted', allowedAgents: ['billing-bot'], + }]; + + const { client: allowed } = await createTestPair({ + clientName: 'billing-bot', services, capabilities, + }); + const allowedResult = await allowed.callTool({ + name: 'execute', arguments: { capability: 'stripe-cap', method: 'GET', path: '/balance' } + }); + expect(allowedResult.isError).toBeFalsy(); + + const { client: denied } = await createTestPair({ + clientName: 'other-agent', services, capabilities, + }); + const deniedResult = await denied.callTool({ + name: 'execute', arguments: { capability: 'stripe-cap', method: 'GET', path: '/balance' } + }); + expect(deniedResult.isError).toBe(true); + }); + + it('should show cap-level access in list_services', async () => { + const services = new Map([ + ['svc', { baseUrl: 'https://api.test.com', auth: { type: 'bearer', key: 'k' } }], + ]); + const capabilities: Capability[] = [ + { name: 'serp', service: 'svc', ttl: '1h', autoApprove: true, access: 'open' }, + { name: 'stripe', service: 'svc', ttl: '1h', autoApprove: true, access: 'restricted' }, + ]; + + const { client } = await createTestPair({ + clientName: 'any-agent', services, capabilities, defaultAccess: 'restricted', + }); + + const result = await client.callTool({ name: 'list_services', arguments: {} }); + const parsed = extractJSON(result); + const accessible = (name: string) => parsed.find((c: any) => c.name === name)?.accessible; + expect(accessible('serp')).toBe(true); + expect(accessible('stripe')).toBe(false); + }); + + it('should trace cap-level access override in explain_access', async () => { + const services = new Map([ + ['svc', { baseUrl: 'https://api.test.com', auth: { type: 'bearer', key: 'k' } }], + ]); + const capabilities: Capability[] = [ + { name: 'open-cap', service: 'svc', ttl: '1h', autoApprove: true, access: 'open' }, + { name: 'locked-cap', service: 'svc', ttl: '1h', autoApprove: true, access: 'restricted' }, + ]; + + const { client } = await createTestPair({ + clientName: 'agent-x', services, capabilities, defaultAccess: 'restricted', + }); + + const openTrace = await client.callTool({ + name: 'explain_access', arguments: { capability: 'open-cap' } + }); + const openParsed = extractJSON(openTrace); + expect(openParsed.allowed).toBe(true); + const openStep = openParsed.trace.find((t: any) => t.check === 'default_access'); + expect(openStep.result).toBe('pass'); + expect(openStep.detail).toContain('capability access'); + + const lockedTrace = await client.callTool({ + name: 'explain_access', arguments: { capability: 'locked-cap' } + }); + const lockedParsed = extractJSON(lockedTrace); + expect(lockedParsed.allowed).toBe(false); + const lockedStep = lockedParsed.trace.find((t: any) => t.check === 'default_access'); + expect(lockedStep.result).toBe('fail'); + expect(lockedStep.detail).toContain('capability access'); + }); + }); + describe('whoami', () => { it('should return the resolved agent identity and accessible capabilities', async () => { const services = new Map([ diff --git a/src/core/mcp-server.ts b/src/core/mcp-server.ts index 0a8a219..1b38eef 100644 --- a/src/core/mcp-server.ts +++ b/src/core/mcp-server.ts @@ -78,7 +78,8 @@ function canAccessCapability( return cap.allowedAgents.includes(agentId); } - if (defaultAccessPolicy === "restricted") { + const effectiveAccess = cap.access ?? defaultAccessPolicy; + if (effectiveAccess === "restricted") { return false; } @@ -108,10 +109,13 @@ export function explainAccessDenial( return null; } - if (defaultAccessPolicy === 'restricted') { + const effectiveAccess = cap.access ?? defaultAccessPolicy; + if (effectiveAccess === 'restricted') { return { reason: 'DEFAULT_ACCESS_RESTRICTED', - detail: `defaultAccess is "restricted" and capability has no allowedAgents list` + detail: cap.access + ? `Capability access is "restricted" and has no allowedAgents list` + : `defaultAccess is "restricted" and capability has no allowedAgents list` }; } @@ -133,6 +137,7 @@ export interface Capability { requiresReason?: boolean; rules?: Rules; // Optional allow/deny patterns allowedAgents?: string[]; // Restrict this capability to specific agent IDs + access?: 'open' | 'restricted'; // Per-capability override of global defaultAccess // Exec mode fields (RFC 0001) mode?: "proxy" | "exec"; allowCommands?: string[]; diff --git a/src/core/tool-handlers.ts b/src/core/tool-handlers.ts index 3224e9e..20bbeb3 100644 --- a/src/core/tool-handlers.ts +++ b/src/core/tool-handlers.ts @@ -354,10 +354,12 @@ export function handleExplainAccess( } if (targetAgentId && (!explainCap.allowedAgents || explainCap.allowedAgents.length === 0)) { - if (ctx.defaultAccess === 'restricted') { - trace.push({ check: 'default_access', result: 'fail', detail: `defaultAccess is "restricted" and no allowedAgents list — agent blocked` }); + const effectiveAccess = explainCap.access ?? ctx.defaultAccess; + const source = explainCap.access ? `capability access` : `global defaultAccess`; + if (effectiveAccess === 'restricted') { + trace.push({ check: 'default_access', result: 'fail', detail: `${source} is "restricted" and no allowedAgents list — agent blocked` }); } else { - trace.push({ check: 'default_access', result: 'pass', detail: `defaultAccess is "${ctx.defaultAccess ?? 'open'}" — agent allowed` }); + trace.push({ check: 'default_access', result: 'pass', detail: `${source} is "${effectiveAccess ?? 'open'}" — agent allowed` }); } } else { trace.push({ check: 'default_access', result: 'skip', detail: targetAgentId ? `allowedAgents list takes precedence` : `No agent ID (admin/CLI)` });