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
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/cli/commands/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(', ')}`);
Expand All @@ -81,6 +83,8 @@ interface CapAddEditOptions {
clearRules?: boolean;
allowedAgents?: string[];
clearAgents?: boolean;
access?: string;
clearAccess?: boolean;
mode?: string;
allowCommands?: string[];
envMap?: string[];
Expand All @@ -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"`);
Expand Down Expand Up @@ -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(', ')}`);
}

Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/serve-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/cli/config-yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface CapabilityConfig {
deny?: string[];
};
allowedAgents?: string[];
access?: 'open' | 'restricted';
mode?: 'proxy' | 'exec';
allowCommands?: string[];
env?: Record<string, string>;
Expand Down
3 changes: 3 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ cap
.option('--allow <pattern...>', 'Allow rules (e.g., "GET /v1/*")')
.option('--deny <pattern...>', 'Deny rules (e.g., "DELETE *")')
.option('--allowed-agents <agents...>', 'Restrict to specific agent IDs')
.option('--access <policy>', 'Per-capability access policy: open or restricted')
.option('--mode <mode>', 'Execution mode: proxy or exec')
.option('--allow-commands <cmds...>', 'Allowed executables for exec mode')
.option('--env-map <mappings...>', 'Env var mappings (KEY=value or KEY={{credential}})')
Expand All @@ -285,6 +286,8 @@ cap
.option('--clear-rules', 'Clear all rules')
.option('--allowed-agents <agents...>', 'Restrict to specific agent IDs')
.option('--clear-agents', 'Remove all agent restrictions')
.option('--access <policy>', 'Per-capability access policy: open, restricted, or inherit')
.option('--clear-access', 'Remove per-capability access override (inherit from global)')
.option('--mode <mode>', 'Execution mode: proxy or exec')
.option('--allow-commands <cmds...>', 'Allowed executables for exec mode')
.option('--env-map <mappings...>', 'Env var mappings (KEY=value or KEY={{credential}})')
Expand Down
130 changes: 130 additions & 0 deletions src/core/mcp-server-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ServiceConfig>([
['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<string, ServiceConfig>([
['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<string, ServiceConfig>([
['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<string, ServiceConfig>([
['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<string, ServiceConfig>([
['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<string, ServiceConfig>([
Expand Down
11 changes: 8 additions & 3 deletions src/core/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ function canAccessCapability(
return cap.allowedAgents.includes(agentId);
}

if (defaultAccessPolicy === "restricted") {
const effectiveAccess = cap.access ?? defaultAccessPolicy;
if (effectiveAccess === "restricted") {
return false;
}

Expand Down Expand Up @@ -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`
};
}

Expand All @@ -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[];
Expand Down
8 changes: 5 additions & 3 deletions src/core/tool-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)` });
Expand Down
Loading