From cef26a1ab2ea3c31d975d7a6f6a065d2dfc539f0 Mon Sep 17 00:00:00 2001 From: Ross Douglas Date: Tue, 17 Mar 2026 06:47:28 +0200 Subject: [PATCH 1/2] feat: `janee overview` command + auto-create capability on `janee add` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `janee overview`: one-screen summary of services, capabilities, and per-agent access. Shows unreachable capabilities (restricted with no allowedAgents). Supports --json. - Simplify `janee add`: always creates a default capability (1h TTL, auto-approve) alongside the service. No more "Create a capability?" prompt — it just happens. Customize with `janee cap edit` afterward. - Fix `diagnose access` to respect per-capability `access` override. Made-with: Cursor --- docs/CHANGELOG.md | 6 + src/cli/commands/add.ts | 128 +++++---------------- src/cli/commands/capability.ts | 7 +- src/cli/commands/diagnose.ts | 10 +- src/cli/commands/overview.test.ts | 181 ++++++++++++++++++++++++++++++ src/cli/commands/overview.ts | 138 +++++++++++++++++++++++ src/cli/commands/serve-mcp.ts | 6 +- src/cli/index.ts | 7 ++ src/core/mcp-server.ts | 28 +++-- src/core/tool-handlers.ts | 21 +++- 10 files changed, 402 insertions(+), 130 deletions(-) create mode 100644 src/cli/commands/overview.test.ts create mode 100644 src/cli/commands/overview.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 000841b..4338825 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to Janee will be documented in this file. ### 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. +- **`janee overview` command** — One-screen summary of services, capabilities, and per-agent access. Shows which agents can reach which capabilities, highlights unreachable capabilities (restricted with no `allowedAgents`), and reports the global default access policy. Supports `--json`. +- **`janee add` always creates a default capability** — Running `janee add resend --key xxx` now creates both the service and a ready-to-use capability in one step. Previously, non-interactive usage with partial prompting (e.g. prompted for test path) would ask whether to create a capability; now it always does. Customize afterward with `janee cap edit`. + +### Fixed + +- **`diagnose access` now respects per-capability `access` override** — The CLI diagnose command was not checking `cap.access` when evaluating the default access policy step, causing incorrect trace output for capabilities with per-capability overrides. ### Changed diff --git a/src/cli/commands/add.ts b/src/cli/commands/add.ts index d33d029..feaea26 100644 --- a/src/cli/commands/add.ts +++ b/src/cli/commands/add.ts @@ -664,104 +664,12 @@ export async function addCommand( saveYAMLConfig(config); - if (options.json) { - // JSON output for non-interactive/json mode - const result: any = { - ok: true, - service: serviceName, - message: `Added service "${serviceName}"` - }; - - // If readline was never opened, we're fully non-interactive. - // Auto-create a capability with sensible defaults. - if (!prompted && !config.capabilities[serviceName]) { - const capConfig: CapabilityConfig = { - service: serviceName, - ttl: '1h', - autoApprove: true, - }; - if (options.exec) { - capConfig.mode = 'exec'; - if (options.allowCommands) capConfig.allowCommands = options.allowCommands; - if (options.envMap) capConfig.env = parseEnvMap(options.envMap); - if (options.workDir) capConfig.workDir = options.workDir; - if (options.timeout) capConfig.timeout = parseInt(options.timeout, 10); - } - config.capabilities[serviceName] = capConfig; - saveYAMLConfig(config); - result.capability = serviceName; - result.capabilityMessage = options.exec - ? `Added exec-mode capability "${serviceName}" (1h TTL, auto-approve, commands: ${(options.allowCommands || []).join(', ')})` - : `Added capability "${serviceName}" (1h TTL, auto-approve)`; - } - - console.log(JSON.stringify(result)); - return; - } - - console.log(`✅ Added service "${serviceName}"`); - console.log(); - - // If readline was never opened, we're fully non-interactive. - // Auto-create a capability with sensible defaults instead of prompting. - if (!prompted) { - if (!config.capabilities[serviceName]) { - const capConfig: CapabilityConfig = { - service: serviceName, - ttl: '1h', - autoApprove: true, - }; - if (options.exec) { - capConfig.mode = 'exec'; - if (options.allowCommands) capConfig.allowCommands = options.allowCommands; - if (options.envMap) capConfig.env = parseEnvMap(options.envMap); - if (options.workDir) capConfig.workDir = options.workDir; - if (options.timeout) capConfig.timeout = parseInt(options.timeout, 10); - } - config.capabilities[serviceName] = capConfig; - saveYAMLConfig(config); - if (options.exec) { - console.log(`✅ Added exec-mode capability "${serviceName}" (1h TTL, auto-approve)`); - console.log(` Allowed commands: ${(options.allowCommands || []).join(', ')}`); - } else { - console.log(`✅ Added capability "${serviceName}" (1h TTL, auto-approve)`); - } - console.log(); - } - console.log("Done! Run 'janee serve' to start."); - return; - } - - // Ask about capability - const createCapAnswer = await getRL().question('Create a capability for this service? (Y/n): '); - const createCap = !createCapAnswer || createCapAnswer.toLowerCase() === 'y' || createCapAnswer.toLowerCase() === 'yes'; - - if (createCap) { - const capNameDefault = serviceName; - const capNameInput = await getRL().question(`Capability name (default: ${capNameDefault}): `); - const capName = capNameInput.trim() || capNameDefault; - - // Check if capability already exists - if (config.capabilities[capName]) { - console.error(`❌ Capability "${capName}" already exists`); - process.exit(1); - } - - const ttlInput = await getRL().question('TTL (e.g., 1h, 30m): '); - const ttl = ttlInput.trim() || '1h'; - - const autoApproveInput = await getRL().question('Auto-approve? (Y/n): '); - const autoApprove = !autoApproveInput || autoApproveInput.toLowerCase() === 'y' || autoApproveInput.toLowerCase() === 'yes'; - - const requiresReasonInput = await getRL().question('Requires reason? (y/N): '); - const requiresReason = requiresReasonInput.toLowerCase() === 'y' || requiresReasonInput.toLowerCase() === 'yes'; - - // Add capability + // Auto-create a default capability unless one already exists + if (!config.capabilities[serviceName]) { const capConfig: CapabilityConfig = { service: serviceName, - ttl, - autoApprove, - requiresReason + ttl: '1h', + autoApprove: true, }; if (options.exec) { capConfig.mode = 'exec'; @@ -770,18 +678,34 @@ export async function addCommand( if (options.workDir) capConfig.workDir = options.workDir; if (options.timeout) capConfig.timeout = parseInt(options.timeout, 10); } - config.capabilities[capName] = capConfig; - + config.capabilities[serviceName] = capConfig; saveYAMLConfig(config); - - console.log(`✅ Added capability "${capName}"`); - console.log(); } - closeRL(); + if (options.json) { + console.log(JSON.stringify({ + ok: true, + service: serviceName, + capability: serviceName, + message: `Added service "${serviceName}" with capability "${serviceName}"`, + })); + closeRL(); + return; + } + console.log(`✅ Added service "${serviceName}"`); + if (options.exec) { + console.log(`✅ Added exec-mode capability "${serviceName}" (1h TTL, auto-approve)`); + console.log(` Allowed commands: ${(options.allowCommands || []).join(', ')}`); + } else { + console.log(`✅ Added capability "${serviceName}" (1h TTL, auto-approve)`); + } + console.log(` Customize with: janee cap edit ${serviceName}`); + console.log(); console.log("Done! Run 'janee serve' to start."); + closeRL(); + } catch (error) { handleCommandError(error, options.json); } diff --git a/src/cli/commands/capability.ts b/src/cli/commands/capability.ts index 04551d9..1beb911 100644 --- a/src/cli/commands/capability.ts +++ b/src/cli/commands/capability.ts @@ -1,4 +1,9 @@ -import { cliError, handleCommandError, parseEnvMap, requireConfig } from '../cli-utils'; +import { + cliError, + handleCommandError, + parseEnvMap, + requireConfig, +} from '../cli-utils'; import { CapabilityConfig, loadYAMLConfig, diff --git a/src/cli/commands/diagnose.ts b/src/cli/commands/diagnose.ts index 58b0713..21a1b6e 100644 --- a/src/cli/commands/diagnose.ts +++ b/src/cli/commands/diagnose.ts @@ -46,12 +46,14 @@ export async function diagnoseAccessCommand( trace.push({ check: 'allowed_agents', result: 'skip', detail: `No allowedAgents restriction on this capability` }); } - // defaultAccess + // defaultAccess (with per-capability override) if (agentId && (!cap.allowedAgents || cap.allowedAgents.length === 0)) { - if (defaultAccess === 'restricted') { - trace.push({ check: 'default_access', result: 'fail', detail: `defaultAccess is "restricted" and no allowedAgents list — agent blocked` }); + const effectiveAccess = cap.access ?? defaultAccess; + const source = cap.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 "${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: agentId ? `allowedAgents list takes precedence` : `No agent ID (admin/CLI)` }); diff --git a/src/cli/commands/overview.test.ts b/src/cli/commands/overview.test.ts new file mode 100644 index 0000000..4b1e579 --- /dev/null +++ b/src/cli/commands/overview.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../config-yaml', () => ({ + loadYAMLConfig: vi.fn(), + hasYAMLConfig: vi.fn(), + saveYAMLConfig: vi.fn(), + getConfigDir: vi.fn(() => '/tmp/janee-test'), + getAuditDir: vi.fn(() => '/tmp/janee-test/logs'), +})); + +import { loadYAMLConfig, hasYAMLConfig } from '../config-yaml'; +import { overviewCommand } from './overview'; + +const mockLoad = vi.mocked(loadYAMLConfig); +const mockHas = vi.mocked(hasYAMLConfig); + +function captureConsole() { + const logs: string[] = []; + const errors: string[] = []; + const origLog = console.log; + const origError = console.error; + console.log = (...args: any[]) => logs.push(args.join(' ')); + console.error = (...args: any[]) => errors.push(args.join(' ')); + return { + logs, + errors, + restore: () => { console.log = origLog; console.error = origError; }, + }; +} + +vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); +}) as any); + +function baseConfig(overrides: any = {}) { + return { + version: '0.3.0', + masterKey: 'test-key', + server: { port: 9100, host: 'localhost', ...overrides.server }, + services: overrides.services ?? {}, + capabilities: overrides.capabilities ?? {}, + }; +} + +describe('overviewCommand', () => { + beforeEach(() => vi.clearAllMocks()); + + it('should show service and capability counts', async () => { + mockHas.mockReturnValue(true); + mockLoad.mockReturnValue(baseConfig({ + services: { + stripe: { baseUrl: 'https://api.stripe.com', auth: { type: 'bearer', key: 'k' } }, + resend: { baseUrl: 'https://api.resend.com', auth: { type: 'bearer', key: 'k' } }, + }, + capabilities: { + stripe: { service: 'stripe', ttl: '1h' }, + resend: { service: 'resend', ttl: '1h' }, + }, + }) as any); + + const cap = captureConsole(); + await overviewCommand(); + cap.restore(); + const output = cap.logs.join('\n'); + expect(output).toContain('2 services'); + expect(output).toContain('2 capabilities'); + }); + + it('should show per-agent access when allowedAgents is set', async () => { + mockHas.mockReturnValue(true); + mockLoad.mockReturnValue(baseConfig({ + services: { + stripe: { baseUrl: 'https://api.stripe.com', auth: { type: 'bearer', key: 'k' } }, + serp: { baseUrl: 'https://serpapi.com', auth: { type: 'bearer', key: 'k' } }, + }, + capabilities: { + stripe: { service: 'stripe', ttl: '1h', allowedAgents: ['billing-bot'] }, + serp: { service: 'serp', ttl: '1h', access: 'open' }, + }, + }) as any); + + const cap = captureConsole(); + await overviewCommand(); + cap.restore(); + const output = cap.logs.join('\n'); + expect(output).toContain('billing-bot'); + expect(output).toContain('stripe (allowed)'); + expect(output).toContain('serp'); + }); + + it('should show unreachable capabilities', async () => { + mockHas.mockReturnValue(true); + mockLoad.mockReturnValue(baseConfig({ + server: { defaultAccess: 'restricted' }, + services: { + slack: { baseUrl: 'https://slack.com/api', auth: { type: 'bearer', key: 'k' } }, + }, + capabilities: { + slack: { service: 'slack', ttl: '1h' }, + }, + }) as any); + + const cap = captureConsole(); + await overviewCommand(); + cap.restore(); + const output = cap.logs.join('\n'); + expect(output).toContain('Unreachable'); + expect(output).toContain('slack'); + }); + + it('should show unreachable for cap-level restricted override', async () => { + mockHas.mockReturnValue(true); + mockLoad.mockReturnValue(baseConfig({ + services: { + stripe: { baseUrl: 'https://api.stripe.com', auth: { type: 'bearer', key: 'k' } }, + }, + capabilities: { + stripe: { service: 'stripe', ttl: '1h', access: 'restricted' }, + }, + }) as any); + + const cap = captureConsole(); + await overviewCommand(); + cap.restore(); + const output = cap.logs.join('\n'); + expect(output).toContain('Unreachable'); + expect(output).toContain('stripe'); + }); + + it('should output JSON', async () => { + mockHas.mockReturnValue(true); + mockLoad.mockReturnValue(baseConfig({ + server: { defaultAccess: 'restricted' }, + services: { + stripe: { baseUrl: 'https://api.stripe.com', auth: { type: 'bearer', key: 'k' } }, + }, + capabilities: { + stripe: { service: 'stripe', ttl: '1h', allowedAgents: ['bot-a'] }, + }, + }) as any); + + const cap = captureConsole(); + await overviewCommand({ json: true }); + cap.restore(); + const parsed = JSON.parse(cap.logs.join('')); + expect(parsed.services).toBe(1); + expect(parsed.capabilities).toBe(1); + expect(parsed.agents['bot-a'].accessible).toContain('stripe'); + expect(parsed.unreachable).toHaveLength(0); + }); + + it('should show open message when no agents configured', async () => { + mockHas.mockReturnValue(true); + mockLoad.mockReturnValue(baseConfig({ + services: { + resend: { baseUrl: 'https://api.resend.com', auth: { type: 'bearer', key: 'k' } }, + }, + capabilities: { + resend: { service: 'resend', ttl: '1h' }, + }, + }) as any); + + const cap = captureConsole(); + await overviewCommand(); + cap.restore(); + const output = cap.logs.join('\n'); + expect(output).toContain('all capabilities are open'); + }); + + it('should handle empty config', async () => { + mockHas.mockReturnValue(true); + mockLoad.mockReturnValue(baseConfig() as any); + + const cap = captureConsole(); + await overviewCommand(); + cap.restore(); + const output = cap.logs.join('\n'); + expect(output).toContain('0 services'); + expect(output).toContain('No capabilities configured'); + }); +}); diff --git a/src/cli/commands/overview.ts b/src/cli/commands/overview.ts new file mode 100644 index 0000000..e2db4e4 --- /dev/null +++ b/src/cli/commands/overview.ts @@ -0,0 +1,138 @@ +import { canAgentAccess } from '../../core/agent-scope'; +import { handleCommandError, requireConfig } from '../cli-utils'; +import { loadYAMLConfig } from '../config-yaml'; +import type { CapabilityConfig, ServiceConfig as YAMLServiceConfig } from '../config-yaml'; + +type AccessPolicy = 'open' | 'restricted' | undefined; + +interface CapAccess { + name: string; + service: string; + mode: string; + access: AccessPolicy; + allowedAgents: string[]; +} + +function resolveEffectiveAccess( + cap: CapabilityConfig, + service: YAMLServiceConfig | undefined, + agentId: string, + globalDefault: AccessPolicy, +): 'allowed' | 'denied' | 'open' { + if (cap.allowedAgents && cap.allowedAgents.length > 0) { + return cap.allowedAgents.includes(agentId) ? 'allowed' : 'denied'; + } + + const effective = cap.access ?? globalDefault; + if (effective === 'restricted') return 'denied'; + + if (!canAgentAccess(agentId, service?.ownership)) return 'denied'; + + return 'open'; +} + +export async function overviewCommand(options: { json?: boolean } = {}): Promise { + try { + requireConfig(options.json); + const config = loadYAMLConfig(); + const globalDefault = config.server?.defaultAccess; + + const serviceNames = Object.keys(config.services); + const capEntries = Object.entries(config.capabilities); + + // Collect all known agent IDs from allowedAgents and ownership + const knownAgents = new Set(); + for (const [, cap] of capEntries) { + for (const a of cap.allowedAgents ?? []) knownAgents.add(a); + } + for (const [, svc] of Object.entries(config.services)) { + for (const a of svc.ownership?.sharedWith ?? []) knownAgents.add(a); + if (svc.ownership?.createdBy) knownAgents.add(svc.ownership.createdBy); + } + + // Build per-capability access info + const caps: CapAccess[] = capEntries.map(([name, cap]) => ({ + name, + service: cap.service, + mode: cap.mode || 'proxy', + access: cap.access, + allowedAgents: cap.allowedAgents ?? [], + })); + + // Build per-agent access map + const agentAccess: Record = {}; + for (const agentId of knownAgents) { + const accessible: string[] = []; + const denied: string[] = []; + for (const [name, cap] of capEntries) { + const svc = config.services[cap.service]; + const result = resolveEffectiveAccess(cap, svc, agentId, globalDefault); + if (result === 'denied') denied.push(name); + else accessible.push(name); + } + agentAccess[agentId] = { accessible, denied }; + } + + // Find capabilities no known agent can reach + const unreachable = caps.filter(cap => { + if (cap.allowedAgents.length > 0) return false; + const effective = cap.access ?? globalDefault; + if (effective === 'restricted') return true; + return false; + }); + + if (options.json) { + console.log(JSON.stringify({ + services: serviceNames.length, + capabilities: caps.length, + globalDefaultAccess: globalDefault ?? 'open', + agents: agentAccess, + unreachable: unreachable.map(c => c.name), + }, null, 2)); + return; + } + + // Human-readable output + console.log(''); + console.log(` ${serviceNames.length} service${serviceNames.length !== 1 ? 's' : ''}, ${caps.length} capabilit${caps.length !== 1 ? 'ies' : 'y'} (defaultAccess: ${globalDefault ?? 'open'})`); + console.log(''); + + if (caps.length === 0) { + console.log(' No capabilities configured. Run `janee add ` to get started.'); + console.log(''); + return; + } + + // Per-agent summary + if (knownAgents.size > 0) { + for (const agentId of [...knownAgents].sort()) { + const { accessible, denied } = agentAccess[agentId]; + if (accessible.length > 0) { + const labels = accessible.map(name => { + const cap = config.capabilities[name]; + if (cap.allowedAgents?.includes(agentId)) return `${name} (allowed)`; + if (cap.access === 'open') return `${name} (open)`; + return name; + }); + console.log(` ${agentId}: ${labels.join(', ')}`); + } else { + console.log(` ${agentId}: (no access)`); + } + } + } else { + console.log(' No agent restrictions configured — all capabilities are open.'); + } + + // Unreachable capabilities + if (unreachable.length > 0) { + console.log(''); + console.log(` Unreachable: ${unreachable.map(c => c.name).join(', ')}`); + console.log(' (restricted with no allowedAgents — no agent can use these)'); + } + + console.log(''); + + } catch (error) { + handleCommandError(error, options.json); + } +} diff --git a/src/cli/commands/serve-mcp.ts b/src/cli/commands/serve-mcp.ts index 3475974..816fc19 100644 --- a/src/cli/commands/serve-mcp.ts +++ b/src/cli/commands/serve-mcp.ts @@ -3,8 +3,6 @@ import { URL } from 'url'; import { AuditLogger } from '../../core/audit'; import { buildAuthHeaders } from '../../core/auth.js'; -import { DEFAULT_TIMEOUT_MS } from '../../core/types'; -import { getErrorMessage } from '../cli-utils'; import { authorityAuthorizeExec, authorityCompleteExec, @@ -31,13 +29,15 @@ import { forwardToolCall, resetAuthoritySession, } from '../../core/runner-proxy.js'; -import { runDoctorChecks } from './doctor'; import { SessionManager } from '../../core/sessions'; +import { DEFAULT_TIMEOUT_MS } from '../../core/types'; +import { getErrorMessage } from '../cli-utils'; import { getAuditDir, hasYAMLConfig, loadYAMLConfig, } from '../config-yaml'; +import { runDoctorChecks } from './doctor'; /** * Load config and convert to MCP format diff --git a/src/cli/index.ts b/src/cli/index.ts index 60aec17..9f6c864 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -27,6 +27,7 @@ import { doctorBundleCommand } from './commands/doctor-bundle'; import { initCommand } from './commands/init'; import { listCommand } from './commands/list'; import { logsCommand } from './commands/logs'; +import { overviewCommand } from './commands/overview'; import { removeCommand } from './commands/remove'; import { revokeCommand } from './commands/revoke'; import { searchCommand } from './commands/search'; @@ -159,6 +160,12 @@ program .option('--runner-key ', 'Shared runner API key (or JANEE_RUNNER_KEY)') .action(authorityCommand); +program + .command('overview') + .description('Show a summary of services, capabilities, and agent access') + .option('--json', 'Output as JSON') + .action(overviewCommand); + program .command('list') .description('List configured services') diff --git a/src/core/mcp-server.ts b/src/core/mcp-server.ts index 1b38eef..d5addda 100644 --- a/src/core/mcp-server.ts +++ b/src/core/mcp-server.ts @@ -30,18 +30,8 @@ import { resolveAgentIdentity, } from './agent-scope.js'; import { AuditLogger } from './audit.js'; -import { - ExecResult, - validateCommand, -} from './exec.js'; -import { - ServiceTestResult, - testServiceConnection, -} from './health.js'; -import { - checkRules, - Rules, -} from './rules.js'; +import { ExecResult } from './exec.js'; +import { Rules } from './rules.js'; import { SessionManager } from './sessions.js'; import { handleExec, @@ -52,6 +42,11 @@ import { handleWhoami, ToolHandlerContext, } from './tool-handlers.js'; +import type { + APIRequest, + APIResponse, +} from './types.js'; +import { DenialError } from './types.js'; // Read version from package.json const packageJsonPath = join(__dirname, "../../package.json"); @@ -185,10 +180,13 @@ export interface ServiceConfig { ownership?: CredentialOwnership; } -export type { APIRequest, APIResponse, DenialDetails, DenialReasonCode } from './types.js'; +export type { + APIRequest, + APIResponse, + DenialDetails, + DenialReasonCode, +} from './types.js'; export { DenialError } from './types.js'; -import type { APIRequest, APIResponse } from './types.js'; -import { DenialError } from './types.js'; export interface ReloadResult { capabilities: Capability[]; diff --git a/src/core/tool-handlers.ts b/src/core/tool-handlers.ts index 20bbeb3..773e0f2 100644 --- a/src/core/tool-handlers.ts +++ b/src/core/tool-handlers.ts @@ -3,13 +3,24 @@ import { CredentialOwnership, } from './agent-scope.js'; import { AuditLogger } from './audit.js'; -import { ExecResult, validateCommand } from './exec.js'; -import { ServiceTestResult, testServiceConnection } from './health.js'; +import { + ExecResult, + validateCommand, +} from './exec.js'; +import { + ServiceTestResult, + testServiceConnection, +} from './health.js'; +import type { + Capability, + ServiceConfig, +} from './mcp-server.js'; import { checkRules } from './rules.js'; import { SessionManager } from './sessions.js'; -import type { APIRequest, APIResponse } from './types.js'; - -import type { Capability, ServiceConfig } from './mcp-server.js'; +import type { + APIRequest, + APIResponse, +} from './types.js'; import { DenialError } from './types.js'; export interface ToolHandlerContext { From dfa4f5ea15d541f5a9d7a500e0f377168e4ecced Mon Sep 17 00:00:00 2001 From: Ross Douglas Date: Tue, 17 Mar 2026 07:16:14 +0200 Subject: [PATCH 2/2] fix: unreachable detection uses per-agent evaluation, conditional add output Addresses review feedback: - Unreachable filter now evaluates resolveEffectiveAccess for all known agents instead of a simplified check, so ownership-based access is correctly considered. - janee add output is conditional: shows "Added capability" only when one was actually created, "Existing capability unchanged" otherwise. - Add test for ownership-based access not flagged as unreachable. Made-with: Cursor --- src/cli/commands/add.ts | 29 ++++++++++++--------- src/cli/commands/overview.test.ts | 26 +++++++++++++++++++ src/cli/commands/overview.ts | 42 ++++++++++--------------------- 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/cli/commands/add.ts b/src/cli/commands/add.ts index feaea26..df8b8d2 100644 --- a/src/cli/commands/add.ts +++ b/src/cli/commands/add.ts @@ -665,7 +665,8 @@ export async function addCommand( saveYAMLConfig(config); // Auto-create a default capability unless one already exists - if (!config.capabilities[serviceName]) { + const capCreated = !config.capabilities[serviceName]; + if (capCreated) { const capConfig: CapabilityConfig = { service: serviceName, ttl: '1h', @@ -683,24 +684,28 @@ export async function addCommand( } if (options.json) { - console.log(JSON.stringify({ - ok: true, - service: serviceName, - capability: serviceName, - message: `Added service "${serviceName}" with capability "${serviceName}"`, - })); + const result: any = { ok: true, service: serviceName, message: `Added service "${serviceName}"` }; + if (capCreated) { + result.capability = serviceName; + result.message += ` with capability "${serviceName}"`; + } + console.log(JSON.stringify(result)); closeRL(); return; } console.log(`✅ Added service "${serviceName}"`); - if (options.exec) { - console.log(`✅ Added exec-mode capability "${serviceName}" (1h TTL, auto-approve)`); - console.log(` Allowed commands: ${(options.allowCommands || []).join(', ')}`); + if (capCreated) { + if (options.exec) { + console.log(`✅ Added exec-mode capability "${serviceName}" (1h TTL, auto-approve)`); + console.log(` Allowed commands: ${(options.allowCommands || []).join(', ')}`); + } else { + console.log(`✅ Added capability "${serviceName}" (1h TTL, auto-approve)`); + } + console.log(` Customize with: janee cap edit ${serviceName}`); } else { - console.log(`✅ Added capability "${serviceName}" (1h TTL, auto-approve)`); + console.log(` Existing capability "${serviceName}" unchanged`); } - console.log(` Customize with: janee cap edit ${serviceName}`); console.log(); console.log("Done! Run 'janee serve' to start."); diff --git a/src/cli/commands/overview.test.ts b/src/cli/commands/overview.test.ts index 4b1e579..d107785 100644 --- a/src/cli/commands/overview.test.ts +++ b/src/cli/commands/overview.test.ts @@ -94,9 +94,11 @@ describe('overviewCommand', () => { server: { defaultAccess: 'restricted' }, services: { slack: { baseUrl: 'https://slack.com/api', auth: { type: 'bearer', key: 'k' } }, + stripe: { baseUrl: 'https://api.stripe.com', auth: { type: 'bearer', key: 'k' } }, }, capabilities: { slack: { service: 'slack', ttl: '1h' }, + stripe: { service: 'stripe', ttl: '1h', allowedAgents: ['bot-a'] }, }, }) as any); @@ -113,9 +115,11 @@ describe('overviewCommand', () => { mockLoad.mockReturnValue(baseConfig({ services: { stripe: { baseUrl: 'https://api.stripe.com', auth: { type: 'bearer', key: 'k' } }, + serp: { baseUrl: 'https://serpapi.com', auth: { type: 'bearer', key: 'k' } }, }, capabilities: { stripe: { service: 'stripe', ttl: '1h', access: 'restricted' }, + serp: { service: 'serp', ttl: '1h', allowedAgents: ['bot-a'] }, }, }) as any); @@ -127,6 +131,28 @@ describe('overviewCommand', () => { expect(output).toContain('stripe'); }); + it('should not flag as unreachable when ownership grants access', async () => { + mockHas.mockReturnValue(true); + mockLoad.mockReturnValue(baseConfig({ + services: { + slack: { + baseUrl: 'https://slack.com/api', + auth: { type: 'bearer', key: 'k' }, + ownership: { accessPolicy: 'shared', sharedWith: ['bot-a'], createdAt: '2026-01-01' }, + }, + }, + capabilities: { + slack: { service: 'slack', ttl: '1h' }, + }, + }) as any); + + const cap = captureConsole(); + await overviewCommand(); + cap.restore(); + const output = cap.logs.join('\n'); + expect(output).not.toContain('Unreachable'); + }); + it('should output JSON', async () => { mockHas.mockReturnValue(true); mockLoad.mockReturnValue(baseConfig({ diff --git a/src/cli/commands/overview.ts b/src/cli/commands/overview.ts index e2db4e4..dd33fbf 100644 --- a/src/cli/commands/overview.ts +++ b/src/cli/commands/overview.ts @@ -5,14 +5,6 @@ import type { CapabilityConfig, ServiceConfig as YAMLServiceConfig } from '../co type AccessPolicy = 'open' | 'restricted' | undefined; -interface CapAccess { - name: string; - service: string; - mode: string; - access: AccessPolicy; - allowedAgents: string[]; -} - function resolveEffectiveAccess( cap: CapabilityConfig, service: YAMLServiceConfig | undefined, @@ -50,15 +42,6 @@ export async function overviewCommand(options: { json?: boolean } = {}): Promise if (svc.ownership?.createdBy) knownAgents.add(svc.ownership.createdBy); } - // Build per-capability access info - const caps: CapAccess[] = capEntries.map(([name, cap]) => ({ - name, - service: cap.service, - mode: cap.mode || 'proxy', - access: cap.access, - allowedAgents: cap.allowedAgents ?? [], - })); - // Build per-agent access map const agentAccess: Record = {}; for (const agentId of knownAgents) { @@ -74,30 +57,31 @@ export async function overviewCommand(options: { json?: boolean } = {}): Promise } // Find capabilities no known agent can reach - const unreachable = caps.filter(cap => { - if (cap.allowedAgents.length > 0) return false; - const effective = cap.access ?? globalDefault; - if (effective === 'restricted') return true; - return false; - }); + const unreachable = capEntries.filter(([name]) => { + if (knownAgents.size === 0) return false; + return [...knownAgents].every(agentId => { + const { denied } = agentAccess[agentId]; + return denied.includes(name); + }); + }).map(([name]) => name); if (options.json) { console.log(JSON.stringify({ services: serviceNames.length, - capabilities: caps.length, + capabilities: capEntries.length, globalDefaultAccess: globalDefault ?? 'open', agents: agentAccess, - unreachable: unreachable.map(c => c.name), + unreachable, }, null, 2)); return; } // Human-readable output console.log(''); - console.log(` ${serviceNames.length} service${serviceNames.length !== 1 ? 's' : ''}, ${caps.length} capabilit${caps.length !== 1 ? 'ies' : 'y'} (defaultAccess: ${globalDefault ?? 'open'})`); + console.log(` ${serviceNames.length} service${serviceNames.length !== 1 ? 's' : ''}, ${capEntries.length} capabilit${capEntries.length !== 1 ? 'ies' : 'y'} (defaultAccess: ${globalDefault ?? 'open'})`); console.log(''); - if (caps.length === 0) { + if (capEntries.length === 0) { console.log(' No capabilities configured. Run `janee add ` to get started.'); console.log(''); return; @@ -126,8 +110,8 @@ export async function overviewCommand(options: { json?: boolean } = {}): Promise // Unreachable capabilities if (unreachable.length > 0) { console.log(''); - console.log(` Unreachable: ${unreachable.map(c => c.name).join(', ')}`); - console.log(' (restricted with no allowedAgents — no agent can use these)'); + console.log(` Unreachable: ${unreachable.join(', ')}`); + console.log(' (no known agent can access these)'); } console.log('');