From 4965d5cd59ff34c7bb24b31aa3b05ff49f49a4cf Mon Sep 17 00:00:00 2001 From: Ross Douglas Date: Tue, 17 Mar 2026 09:35:45 +0200 Subject: [PATCH 1/3] fix: CLI gotcha audit + consolidate duplicated access/TTL logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gotcha fixes: - whoami and doctor-bundle now respect per-capability `access` field - Error on contradictory option pairs (--allowed-agents + --clear-agents, etc.) - cap add --mode exec without --allow-commands now errors - janee add --timeout validates NaN/negative values - --access help text fixed (no more "inherit" that doesn't exist) - cap edit with no options now errors instead of silent success - TTL format validated at save time - Warn when exec-mode options used on proxy capabilities (and vice versa) - --access warning only fires for pre-existing allowedAgents, not same-command - revoke prefix match errors on ambiguous matches - logs JSON error format normalized to { ok: false, error } - Overview: cyan for agent-specific access, green for globally open Consolidation: - canAccessCapability() and resolveAccess() extracted to agent-scope.ts as single source of truth — removed 5 duplicated implementations from mcp-server.ts, whoami.ts, doctor-bundle.ts, overview.ts, authority.ts - validateTTL() and parseTTL() extracted to types.ts — removed duplicates from capability.ts and tool-handlers.ts Made-with: Cursor --- src/cli/commands/add.ts | 7 ++- src/cli/commands/capability.ts | 59 +++++++++++++++++-- src/cli/commands/diagnose.ts | 5 +- src/cli/commands/doctor-bundle.ts | 11 +--- src/cli/commands/logs.ts | 2 +- src/cli/commands/new-commands.test.ts | 1 + src/cli/commands/overview.test.ts | 19 +++++-- src/cli/commands/overview.ts | 81 ++++++++++++--------------- src/cli/commands/revoke.ts | 15 ++++- src/cli/commands/whoami.ts | 23 ++------ src/cli/index.ts | 2 +- src/core/agent-scope.ts | 57 +++++++++++++++++++ src/core/authority.ts | 29 ++++++---- src/core/mcp-server.ts | 29 +--------- src/core/tool-handlers.ts | 23 +++----- src/core/types.ts | 18 ++++++ 16 files changed, 238 insertions(+), 143 deletions(-) diff --git a/src/cli/commands/add.ts b/src/cli/commands/add.ts index df8b8d2..584777e 100644 --- a/src/cli/commands/add.ts +++ b/src/cli/commands/add.ts @@ -677,7 +677,12 @@ export async function addCommand( 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); + if (options.timeout) { + capConfig.timeout = parseInt(options.timeout, 10); + if (isNaN(capConfig.timeout) || capConfig.timeout <= 0) { + throw new Error(`Invalid timeout "${options.timeout}"`); + } + } } config.capabilities[serviceName] = capConfig; saveYAMLConfig(config); diff --git a/src/cli/commands/capability.ts b/src/cli/commands/capability.ts index 1beb911..a98c1cd 100644 --- a/src/cli/commands/capability.ts +++ b/src/cli/commands/capability.ts @@ -1,3 +1,4 @@ +import { validateTTL } from '../../core/types'; import { cliError, handleCommandError, @@ -98,7 +99,14 @@ interface CapAddEditOptions { json?: boolean; } -function applyCapabilityOptions(cap: CapabilityConfig, options: CapAddEditOptions): void { +function applyCapabilityOptions(cap: CapabilityConfig, options: CapAddEditOptions, hadAllowedAgents = false): void { + if (options.allowedAgents && options.clearAgents) { + throw new Error('Conflicting options: --allowed-agents and --clear-agents cannot be used together'); + } + if (options.access && options.clearAccess) { + throw new Error('Conflicting options: --access and --clear-access cannot be used together'); + } + if (options.allowedAgents) { cap.allowedAgents = options.allowedAgents.flatMap(a => a.split(',').map(s => s.trim()).filter(Boolean)); } @@ -109,6 +117,10 @@ function applyCapabilityOptions(cap: CapabilityConfig, options: CapAddEditOption if (options.access !== 'open' && options.access !== 'restricted') { throw new Error(`Invalid access policy "${options.access}" — must be "open" or "restricted"`); } + if (hadAllowedAgents && !options.clearAgents && !options.allowedAgents) { + console.error(`⚠️ This capability has allowedAgents, which take precedence over --access.`); + console.error(` To apply access policy, also pass --clear-agents.`); + } cap.access = options.access; } if (options.clearAccess) { @@ -118,6 +130,9 @@ function applyCapabilityOptions(cap: CapabilityConfig, options: CapAddEditOption if (options.mode !== 'proxy' && options.mode !== 'exec') { throw new Error(`Invalid mode "${options.mode}" — must be "proxy" or "exec"`); } + if (options.mode === 'exec' && !options.allowCommands?.length && !cap.allowCommands?.length) { + throw new Error('Exec mode requires --allow-commands'); + } cap.mode = options.mode; } if (options.allowCommands) { @@ -131,7 +146,12 @@ function applyCapabilityOptions(cap: CapabilityConfig, options: CapAddEditOption } if (options.timeout) { cap.timeout = parseInt(options.timeout, 10); - if (isNaN(cap.timeout)) throw new Error(`Invalid timeout "${options.timeout}"`); + if (isNaN(cap.timeout) || cap.timeout <= 0) throw new Error(`Invalid timeout "${options.timeout}"`); + } + + const effectiveMode = cap.mode ?? 'proxy'; + if (effectiveMode === 'proxy' && (options.allowCommands || options.envMap || options.workDir || options.timeout)) { + console.error(`⚠️ Exec-mode options (--allow-commands, --env-map, --work-dir, --timeout) have no effect on proxy-mode capabilities.`); } } @@ -156,9 +176,12 @@ export async function capabilityAddCommand( cliError(`Service "${options.service}" not found. Add it first with 'janee add'.`, options.json); } + const ttl = options.ttl || '1h'; + validateTTL(ttl); + const capability: CapabilityConfig = { service: options.service, - ttl: options.ttl || '1h', + ttl, autoApprove: options.autoApprove, requiresReason: options.requiresReason, }; @@ -171,6 +194,10 @@ export async function capabilityAddCommand( applyCapabilityOptions(capability, options); + if ((capability.mode === 'exec') && capability.rules) { + console.error(`⚠️ Path rules (--allow/--deny) have no effect on exec-mode capabilities. Use --allow-commands instead.`); + } + config.capabilities[name] = capability; saveYAMLConfig(config); @@ -210,11 +237,29 @@ export async function capabilityEditCommand( } const capability = config.capabilities[name]; + const hadAllowedAgents = !!(capability.allowedAgents?.length); - if (options.ttl) capability.ttl = options.ttl; + const hasChanges = options.ttl || options.autoApprove !== undefined || + options.requiresReason !== undefined || options.clearRules || options.allow || + options.deny || options.allowedAgents || options.clearAgents || options.access || + options.clearAccess || options.mode || options.allowCommands || options.envMap || + options.workDir || options.timeout; + + if (!hasChanges) { + cliError('No changes specified. See `janee cap edit --help` for options.', options.json); + } + + if (options.ttl) { + validateTTL(options.ttl); + capability.ttl = options.ttl; + } if (options.autoApprove !== undefined) capability.autoApprove = options.autoApprove; if (options.requiresReason !== undefined) capability.requiresReason = options.requiresReason; + if (options.clearRules && (options.allow || options.deny)) { + throw new Error('Conflicting options: --clear-rules and --allow/--deny cannot be used together'); + } + if (options.clearRules) { delete capability.rules; } else if (options.allow || options.deny) { @@ -223,7 +268,11 @@ export async function capabilityEditCommand( if (options.deny) capability.rules.deny = options.deny; } - applyCapabilityOptions(capability, options); + applyCapabilityOptions(capability, options, hadAllowedAgents); + + if ((options.allow || options.deny) && (capability.mode === 'exec')) { + console.error(`⚠️ Path rules (--allow/--deny) have no effect on exec-mode capabilities. Use --allow-commands instead.`); + } saveYAMLConfig(config); diff --git a/src/cli/commands/diagnose.ts b/src/cli/commands/diagnose.ts index 21a1b6e..48b1292 100644 --- a/src/cli/commands/diagnose.ts +++ b/src/cli/commands/diagnose.ts @@ -1,6 +1,9 @@ import { canAgentAccess } from '../../core/agent-scope'; import { checkRules } from '../../core/rules'; -import { handleCommandError, requireConfig } from '../cli-utils'; +import { + handleCommandError, + requireConfig, +} from '../cli-utils'; import { loadYAMLConfig } from '../config-yaml'; interface TraceStep { diff --git a/src/cli/commands/doctor-bundle.ts b/src/cli/commands/doctor-bundle.ts index 75348c9..73526e1 100644 --- a/src/cli/commands/doctor-bundle.ts +++ b/src/cli/commands/doctor-bundle.ts @@ -1,5 +1,6 @@ import { writeFileSync } from 'fs'; +import { canAccessCapability } from '../../core/agent-scope'; import { AuditEvent, AuditLogger, @@ -96,15 +97,7 @@ export async function doctorBundleCommand( const denied: string[] = []; for (const [name, cap] of Object.entries(config.capabilities)) { - const c = cap as any; - let allowed = true; - - if (c.allowedAgents && c.allowedAgents.length > 0) { - allowed = c.allowedAgents.includes(agentId); - } else if (config.server?.defaultAccess === 'restricted') { - allowed = false; - } - + const allowed = canAccessCapability(agentId, cap, config.services[cap.service], config.server?.defaultAccess); (allowed ? accessible : denied).push(name); } diff --git a/src/cli/commands/logs.ts b/src/cli/commands/logs.ts index 1f43117..80f6e9a 100644 --- a/src/cli/commands/logs.ts +++ b/src/cli/commands/logs.ts @@ -14,7 +14,7 @@ export async function logsCommand(options: { if (options.follow) { // JSON mode not supported for follow (streaming) if (options.json) { - console.log(JSON.stringify({ error: '--json not supported with --follow' }, null, 2)); + console.log(JSON.stringify({ ok: false, error: '--json not supported with --follow' }, null, 2)); process.exit(1); } diff --git a/src/cli/commands/new-commands.test.ts b/src/cli/commands/new-commands.test.ts index a60890d..096941c 100644 --- a/src/cli/commands/new-commands.test.ts +++ b/src/cli/commands/new-commands.test.ts @@ -184,6 +184,7 @@ describe('capabilityEditCommand — extended flags', () => { const cap = captureConsole(); await capabilityEditCommand('stripe_read', { mode: 'exec', + allowCommands: ['curl'], timeout: '10000', json: true, }); diff --git a/src/cli/commands/overview.test.ts b/src/cli/commands/overview.test.ts index d107785..4bee09a 100644 --- a/src/cli/commands/overview.test.ts +++ b/src/cli/commands/overview.test.ts @@ -1,4 +1,16 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { + hasYAMLConfig, + loadYAMLConfig, +} from '../config-yaml'; +import { overviewCommand } from './overview'; vi.mock('../config-yaml', () => ({ loadYAMLConfig: vi.fn(), @@ -8,9 +20,6 @@ vi.mock('../config-yaml', () => ({ 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); @@ -84,7 +93,7 @@ describe('overviewCommand', () => { cap.restore(); const output = cap.logs.join('\n'); expect(output).toContain('billing-bot'); - expect(output).toContain('stripe (allowed)'); + expect(output).toContain('stripe'); expect(output).toContain('serp'); }); diff --git a/src/cli/commands/overview.ts b/src/cli/commands/overview.ts index dd33fbf..e70059d 100644 --- a/src/cli/commands/overview.ts +++ b/src/cli/commands/overview.ts @@ -1,27 +1,19 @@ -import { canAgentAccess } from '../../core/agent-scope'; -import { handleCommandError, requireConfig } from '../cli-utils'; +import { resolveAccess } 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; - -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'; -} +const useColor = process.stdout.isTTY !== false && !process.env.NO_COLOR; +const c = { + bold: (s: string) => useColor ? `\x1b[1m${s}\x1b[22m` : s, + dim: (s: string) => useColor ? `\x1b[2m${s}\x1b[22m` : s, + green: (s: string) => useColor ? `\x1b[32m${s}\x1b[39m` : s, + yellow: (s: string) => useColor ? `\x1b[33m${s}\x1b[39m` : s, + red: (s: string) => useColor ? `\x1b[31m${s}\x1b[39m` : s, + cyan: (s: string) => useColor ? `\x1b[36m${s}\x1b[39m` : s, +}; export async function overviewCommand(options: { json?: boolean } = {}): Promise { try { @@ -42,35 +34,35 @@ export async function overviewCommand(options: { json?: boolean } = {}): Promise if (svc.ownership?.createdBy) knownAgents.add(svc.ownership.createdBy); } - // Build per-agent access map - const agentAccess: Record = {}; + // Build per-agent access map with access type + const agentAccess: Record = {}; for (const agentId of knownAgents) { - const accessible: string[] = []; + const accessible: { name: string; type: 'allowed' | 'open' }[] = []; const denied: string[] = []; for (const [name, cap] of capEntries) { const svc = config.services[cap.service]; - const result = resolveEffectiveAccess(cap, svc, agentId, globalDefault); + const result = resolveAccess(agentId, cap, svc, globalDefault); if (result === 'denied') denied.push(name); - else accessible.push(name); + else accessible.push({ name, type: result }); } agentAccess[agentId] = { accessible, denied }; } - // Find capabilities no known agent can reach const unreachable = capEntries.filter(([name]) => { if (knownAgents.size === 0) return false; - return [...knownAgents].every(agentId => { - const { denied } = agentAccess[agentId]; - return denied.includes(name); - }); + return [...knownAgents].every(agentId => agentAccess[agentId].denied.includes(name)); }).map(([name]) => name); if (options.json) { + const jsonAgents: Record = {}; + for (const [agentId, data] of Object.entries(agentAccess)) { + jsonAgents[agentId] = { accessible: data.accessible.map(a => a.name), denied: data.denied }; + } console.log(JSON.stringify({ services: serviceNames.length, capabilities: capEntries.length, globalDefaultAccess: globalDefault ?? 'open', - agents: agentAccess, + agents: jsonAgents, unreachable, }, null, 2)); return; @@ -78,11 +70,11 @@ export async function overviewCommand(options: { json?: boolean } = {}): Promise // Human-readable output console.log(''); - console.log(` ${serviceNames.length} service${serviceNames.length !== 1 ? 's' : ''}, ${capEntries.length} capabilit${capEntries.length !== 1 ? 'ies' : 'y'} (defaultAccess: ${globalDefault ?? 'open'})`); + console.log(` ${c.bold(`${serviceNames.length}`)} service${serviceNames.length !== 1 ? 's' : ''}, ${c.bold(`${capEntries.length}`)} capabilit${capEntries.length !== 1 ? 'ies' : 'y'} ${c.dim(`(defaultAccess: ${globalDefault ?? 'open'})`)}`); console.log(''); if (capEntries.length === 0) { - console.log(' No capabilities configured. Run `janee add ` to get started.'); + console.log(` No capabilities configured. Run ${c.cyan('janee add ')} to get started.`); console.log(''); return; } @@ -90,17 +82,14 @@ export async function overviewCommand(options: { json?: boolean } = {}): Promise // Per-agent summary if (knownAgents.size > 0) { for (const agentId of [...knownAgents].sort()) { - const { accessible, denied } = agentAccess[agentId]; + const { accessible } = 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(', ')}`); + const labels = accessible.map(a => + a.type === 'allowed' ? c.cyan(a.name) : c.green(a.name) + ); + console.log(` ${c.bold(agentId)}: ${labels.join(c.dim(', '))}`); } else { - console.log(` ${agentId}: (no access)`); + console.log(` ${c.bold(agentId)}: ${c.dim('(no access)')}`); } } } else { @@ -110,8 +99,8 @@ export async function overviewCommand(options: { json?: boolean } = {}): Promise // Unreachable capabilities if (unreachable.length > 0) { console.log(''); - console.log(` Unreachable: ${unreachable.join(', ')}`); - console.log(' (no known agent can access these)'); + console.log(` ${c.red('Unreachable')}: ${unreachable.map(n => c.yellow(n)).join(', ')}`); + console.log(` ${c.dim('(no known agent can access these)')}`); } console.log(''); diff --git a/src/cli/commands/revoke.ts b/src/cli/commands/revoke.ts index f2e933f..cf55450 100644 --- a/src/cli/commands/revoke.ts +++ b/src/cli/commands/revoke.ts @@ -16,16 +16,25 @@ export async function revokeCommand(sessionIdPrefix: string): Promise { const data = fs.readFileSync(sessionsFile, 'utf8'); const sessions: SerializedSession[] = JSON.parse(data); - // Find session by prefix - const session = sessions.find(s => s.id.startsWith(sessionIdPrefix)); + const matches = sessions.filter(s => s.id.startsWith(sessionIdPrefix)); - if (!session) { + if (matches.length === 0) { console.error(`❌ Session not found: ${sessionIdPrefix}`); console.error(''); console.error('Run: janee sessions'); process.exit(1); } + if (matches.length > 1) { + console.error(`❌ Ambiguous prefix "${sessionIdPrefix}" matches ${matches.length} sessions. Be more specific.`); + for (const m of matches) { + console.error(` ${m.id.substring(0, 24)}... (${m.capability})`); + } + process.exit(1); + } + + const session = matches[0]; + if (session.revoked) { console.log(`⚠️ Session already revoked: ${session.id.substring(0, 20)}...`); return; diff --git a/src/cli/commands/whoami.ts b/src/cli/commands/whoami.ts index 1d6e156..9959d6e 100644 --- a/src/cli/commands/whoami.ts +++ b/src/cli/commands/whoami.ts @@ -1,21 +1,10 @@ -import { canAgentAccess } from '../../core/agent-scope'; -import { handleCommandError, requireConfig } from '../cli-utils'; +import { canAccessCapability } from '../../core/agent-scope'; +import { + handleCommandError, + requireConfig, +} from '../cli-utils'; import { loadYAMLConfig } from '../config-yaml'; -function canAccessCapability( - agentId: string | undefined, - cap: { allowedAgents?: string[]; service: string }, - services: Record, - defaultAccess?: string, -): boolean { - if (!agentId) return true; - if (cap.allowedAgents && cap.allowedAgents.length > 0) { - return cap.allowedAgents.includes(agentId); - } - if (defaultAccess === 'restricted') return false; - return canAgentAccess(agentId, services[cap.service]?.ownership); -} - export async function whoamiCommand( options: { agent?: string; json?: boolean } = {}, ): Promise { @@ -32,7 +21,7 @@ export async function whoamiCommand( for (const name of capNames) { const cap = config.capabilities[name]; - if (canAccessCapability(agentId, cap, config.services, defaultAccess)) { + if (canAccessCapability(agentId, cap, config.services[cap.service], defaultAccess)) { accessible.push(name); } else { denied.push(name); diff --git a/src/cli/index.ts b/src/cli/index.ts index 9f6c864..e25bde8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -293,7 +293,7 @@ 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('--access ', 'Per-capability access policy: open or restricted (use --clear-access to 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') diff --git a/src/core/agent-scope.ts b/src/core/agent-scope.ts index 5f6527a..37bf24d 100644 --- a/src/core/agent-scope.ts +++ b/src/core/agent-scope.ts @@ -177,3 +177,60 @@ export function revokeAccess( return updated; } + +// --------------------------------------------------------------------------- +// Shared capability access evaluation +// --------------------------------------------------------------------------- + +/** Minimal shape needed for access checks — satisfied by both runtime Capability and CLI CapabilityConfig */ +export interface AccessCheckCapability { + allowedAgents?: string[]; + access?: 'open' | 'restricted'; + service: string; +} + +/** Minimal shape needed for service ownership lookups */ +export interface AccessCheckService { + ownership?: CredentialOwnership; +} + +export type AccessResult = 'allowed' | 'open' | 'denied'; + +/** + * Single source of truth for "can this agent access this capability?" + * No agentId (CLI/admin) always gets access. + */ +export function canAccessCapability( + agentId: string | undefined, + cap: AccessCheckCapability, + service: AccessCheckService | undefined, + defaultAccessPolicy: 'open' | 'restricted' | undefined, +): boolean { + return resolveAccess(agentId, cap, service, defaultAccessPolicy) !== 'denied'; +} + +/** + * Richer version that returns *how* access was granted: + * - 'allowed': agent is explicitly listed in allowedAgents + * - 'open': access is open (global or per-capability policy) + * - 'denied': agent cannot access + */ +export function resolveAccess( + agentId: string | undefined, + cap: AccessCheckCapability, + service: AccessCheckService | undefined, + defaultAccessPolicy: 'open' | 'restricted' | undefined, +): AccessResult { + if (!agentId) return 'open'; + + if (cap.allowedAgents && cap.allowedAgents.length > 0) { + return cap.allowedAgents.includes(agentId) ? 'allowed' : 'denied'; + } + + const effective = cap.access ?? defaultAccessPolicy; + if (effective === 'restricted') return 'denied'; + + if (!canAgentAccess(agentId, service?.ownership)) return 'denied'; + + return 'open'; +} diff --git a/src/core/authority.ts b/src/core/authority.ts index 11f1d6c..ce2bd17 100644 --- a/src/core/authority.ts +++ b/src/core/authority.ts @@ -1,14 +1,24 @@ -import { randomUUID, timingSafeEqual } from "crypto"; -import express from "express"; +import { + randomUUID, + timingSafeEqual, +} from 'crypto'; +import express from 'express'; +import { canAccessCapability } from './agent-scope.js'; import { buildExecEnv, hashPolicyFingerprint, validateCommand, -} from "./exec.js"; -import { DEFAULT_TIMEOUT_MS } from "./types.js"; -import { getInstallationToken, GitHubAppCredentials } from "./github-app.js"; -import { ServiceTestResult, testServiceConnection } from "./health.js"; +} from './exec.js'; +import { + getInstallationToken, + GitHubAppCredentials, +} from './github-app.js'; +import { + ServiceTestResult, + testServiceConnection, +} from './health.js'; +import { DEFAULT_TIMEOUT_MS } from './types.js'; export interface RunnerIdentity { runnerId: string; @@ -207,12 +217,7 @@ export function buildAuthorityHooks( if (!cap) throw new Error(`Unknown capability: ${req.capabilityId}`); if (cap.mode !== "exec") throw new Error("Capability is not exec-mode"); - if ( - cap.allowedAgents && - cap.allowedAgents.length > 0 && - req.agentId && - !cap.allowedAgents.includes(req.agentId) - ) { + if (!canAccessCapability(req.agentId, cap, undefined, undefined)) { throw new Error( `Agent ${req.agentId} is not allowed for capability ${cap.name}`, ); diff --git a/src/core/mcp-server.ts b/src/core/mcp-server.ts index d5addda..4b467a6 100644 --- a/src/core/mcp-server.ts +++ b/src/core/mcp-server.ts @@ -25,6 +25,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { + canAccessCapability, canAgentAccess, CredentialOwnership, resolveAgentIdentity, @@ -54,33 +55,6 @@ const pkgVersion = JSON.parse(readFileSync(packageJsonPath, "utf8")).version || "0.0.0"; -/** - * Check whether an agent can access a capability. - * Checks capability-level allowedAgents first, then falls back to - * service-level ownership and the global defaultAccess policy. - * - * No agentId (e.g. CLI/admin) always gets access. - */ -function canAccessCapability( - agentId: string | undefined, - cap: Capability, - service: ServiceConfig | undefined, - defaultAccessPolicy: "open" | "restricted" | undefined, -): boolean { - if (!agentId) return true; - - if (cap.allowedAgents && cap.allowedAgents.length > 0) { - return cap.allowedAgents.includes(agentId); - } - - const effectiveAccess = cap.access ?? defaultAccessPolicy; - if (effectiveAccess === "restricted") { - return false; - } - - return canAgentAccess(agentId, service?.ownership); -} - export type AccessDenialReason = 'AGENT_NOT_ALLOWED' | 'DEFAULT_ACCESS_RESTRICTED' | 'OWNERSHIP_DENIED'; /** @@ -561,7 +535,6 @@ export function createMCPServer(options: MCPServerOptions): MCPServerResult { resolveAgent: resolveAgentFromRequest, clientSessions, explainAccessDenial, - canAccessCapability, }; switch (name) { diff --git a/src/core/tool-handlers.ts b/src/core/tool-handlers.ts index 773e0f2..66d2838 100644 --- a/src/core/tool-handlers.ts +++ b/src/core/tool-handlers.ts @@ -1,4 +1,5 @@ import { + canAccessCapability, canAgentAccess, CredentialOwnership, } from './agent-scope.js'; @@ -21,7 +22,10 @@ import type { APIRequest, APIResponse, } from './types.js'; -import { DenialError } from './types.js'; +import { + DenialError, + parseTTL, +} from './types.js'; export interface ToolHandlerContext { getCapabilities: () => Capability[]; @@ -36,7 +40,6 @@ export interface ToolHandlerContext { resolveAgent: (extra: any, args: any) => string | undefined; clientSessions: Map; explainAccessDenial: (agentId: string | undefined, cap: Capability, service: ServiceConfig | undefined, defaultAccess: 'open' | 'restricted' | undefined) => { reason: string; detail: string } | null; - canAccessCapability: (agentId: string | undefined, cap: Capability, service: ServiceConfig | undefined, defaultAccess: 'open' | 'restricted' | undefined) => boolean; } type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean }; @@ -45,14 +48,6 @@ function textResult(data: unknown): ToolResult { return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; } -function parseTTL(ttl: string): number { - const match = ttl.match(/^(\d+)(s|m|h|d)$/); - if (!match) throw new Error(`Invalid TTL format: ${ttl}`); - const [, num, unit] = match; - const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; - return parseInt(num) * multipliers[unit]; -} - // --------------------------------------------------------------------------- // execute // --------------------------------------------------------------------------- @@ -104,7 +99,7 @@ export async function handleExecute( const executeAgentId = ctx.resolveAgent(extra, args); const executeSvc = services.get(cap.service); - if (!ctx.canAccessCapability(executeAgentId, cap, executeSvc, ctx.defaultAccess)) { + if (!canAccessCapability(executeAgentId, cap, executeSvc, ctx.defaultAccess)) { const denialDetail = ctx.explainAccessDenial(executeAgentId, cap, executeSvc, ctx.defaultAccess); ctx.auditLogger.logDenied(cap.service, method, path, 'Agent does not have access to this capability', reason); throw new DenialError( @@ -189,7 +184,7 @@ export async function handleExec( const execAgentId = ctx.resolveAgent(extra, args); const execSvc = services.get(execCap.service); - if (!ctx.canAccessCapability(execAgentId, execCap, execSvc, ctx.defaultAccess)) { + if (!canAccessCapability(execAgentId, execCap, execSvc, ctx.defaultAccess)) { const execDenialDetail = ctx.explainAccessDenial(execAgentId, execCap, execSvc, ctx.defaultAccess); ctx.auditLogger.logDenied(execCap.service, 'EXEC', execCommand.join(' '), 'Agent does not have access to this capability', execReason); throw new DenialError( @@ -426,11 +421,11 @@ export function handleWhoami( const services = ctx.getServices(); const accessibleCaps = capabilities - .filter(cap => ctx.canAccessCapability(whoamiAgentId, cap, services.get(cap.service), ctx.defaultAccess)) + .filter(cap => canAccessCapability(whoamiAgentId, cap, services.get(cap.service), ctx.defaultAccess)) .map(cap => cap.name); const deniedCaps = capabilities - .filter(cap => !ctx.canAccessCapability(whoamiAgentId, cap, services.get(cap.service), ctx.defaultAccess)) + .filter(cap => !canAccessCapability(whoamiAgentId, cap, services.get(cap.service), ctx.defaultAccess)) .map(cap => cap.name); return textResult({ diff --git a/src/core/types.ts b/src/core/types.ts index 55d6ab5..618eeb4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -2,6 +2,24 @@ export const DEFAULT_TIMEOUT_MS = 30_000; export const REDACTED = '[REDACTED]'; export const MIN_SCRUB_LENGTH = 8; +const TTL_PATTERN = /^(\d+)(s|m|h|d)$/; +const TTL_MULTIPLIERS: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + +/** Validate a TTL string format (e.g. "30s", "5m", "1h", "7d"). Throws on invalid input. */ +export function validateTTL(ttl: string): void { + if (!TTL_PATTERN.test(ttl)) { + throw new Error(`Invalid TTL "${ttl}" — expected format like 30s, 5m, 1h, 7d`); + } +} + +/** Parse a TTL string into seconds. Throws on invalid input. */ +export function parseTTL(ttl: string): number { + const match = ttl.match(TTL_PATTERN); + if (!match) throw new Error(`Invalid TTL format: ${ttl}`); + const [, num, unit] = match; + return parseInt(num) * TTL_MULTIPLIERS[unit]; +} + export interface APIRequest { service: string; path: string; From 2a3a0bedeb2280bd6bd7957aed47f42ad7300fe6 Mon Sep 17 00:00:00 2001 From: Ross Douglas Date: Tue, 17 Mar 2026 09:37:35 +0200 Subject: [PATCH 2/3] docs: update changelog and README with overview command + gotcha fixes Made-with: Cursor --- README.md | 24 +++++++++++++++++++++++- docs/CHANGELOG.md | 19 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 03b2540..0b8d7ae 100644 --- a/README.md +++ b/README.md @@ -587,6 +587,8 @@ janee add # Add a service (interactive) janee add stripe -u https://api.stripe.com -k sk_xxx # Add with args janee remove # Remove a service janee remove --yes # Remove without confirmation +janee overview # One-screen summary of who can access what +janee overview --json # Output as JSON janee list # List configured services janee list --json # Output as JSON (for integrations) janee search [query] # Search service directory @@ -606,8 +608,28 @@ janee logs --json # Output as JSON janee sessions # List active sessions janee sessions --json # Output as JSON janee revoke # Kill a session +janee status # Config and health status +janee whoami --agent # Preview what an agent can access +janee diagnose access --agent # Trace why access is allowed/denied ``` +### Overview + +`janee overview` gives you a one-screen summary of your entire configuration — which agents can access which capabilities, and whether anything is unreachable: + +``` + 22 services, 32 capabilities (defaultAccess: restricted) + + creature:must-trade: bybit, serper + creature:proof-ceo: gh-proof-ceo-proxy, cloudflare, resend, serper, ... + cursor-vscode: devto, cloudflare, serper, proof-admin, twitter, ... + + Unreachable: mexc, google-analytics, fal + (no known agent can access these) +``` + +Agent-specific access (via `allowedAgents`) is colored cyan; globally open access (via `access: open` or default policy) is colored green. Use `--json` for machine-readable output. + ### Non-interactive Setup (for AI agents) AI agents can't respond to interactive prompts. Use `--*-from-env` flags to read credentials from environment variables — this keeps secrets out of the agent's context window: @@ -671,7 +693,7 @@ Agent never touches the real key. - **Encryption**: Keys stored with AES-256-GCM - **Agent identity**: Derived from `clientInfo.name` in the MCP initialize handshake — no custom headers needed - **Agent isolation**: Each agent gets its own session with isolated identity (HTTP transport creates a Server+Transport per session) -- **Access control**: Per-capability `allowedAgents` whitelist + server-wide `defaultAccess` policy +- **Access control**: Per-capability `allowedAgents` whitelist + server-wide `defaultAccess` policy + per-capability `access: open/restricted` override - **Credential scoping**: Agent-created credentials default to `agent-only` - **Audit log**: Every request logged to `~/.janee/logs/` - **Sessions**: Time-limited, revocable diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3c3e92f..ce8bc48 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,24 @@ All notable changes to Janee will be documented in this file. ## [Unreleased] -_(empty)_ +### Fixed + +- **`whoami` and `doctor bundle` now respect per-capability `access` field** — Both commands were only checking the global `defaultAccess`, ignoring capability-level `access: open/restricted` overrides. This could report incorrect access for agents. +- **Contradictory CLI flags now error** — `--allowed-agents` + `--clear-agents`, `--access` + `--clear-access`, and `--clear-rules` + `--allow`/`--deny` now produce a clear error instead of silently discarding one option. +- **`cap add --mode exec` without `--allow-commands` now errors** — Previously created an unusable exec-mode capability with no allowed commands. +- **`janee add --timeout` validates input** — Invalid values (NaN, zero, negative) now error instead of saving broken config. +- **TTL format validated at save time** — `janee cap add --ttl garbage` now errors with expected format hint. +- **`cap edit` with no options now errors** — Previously reported silent success with no changes made. +- **`--access` help text corrected** — Removed nonexistent "inherit" option; points to `--clear-access` instead. +- **`--access` warning scoped correctly** — Warning about `allowedAgents` precedence no longer fires when both are set in the same command. +- **`revoke` prefix ambiguity check** — Ambiguous session ID prefixes now error with a list of matches instead of silently revoking the first. +- **`logs --json --follow` error format** — Normalized to `{ ok: false, error }` for consistency. + +### Changed + +- **Consolidate access evaluation** — `canAccessCapability()` and `resolveAccess()` extracted to `agent-scope.ts` as single source of truth. Removed 5 duplicated implementations from `mcp-server.ts`, `whoami.ts`, `doctor-bundle.ts`, `overview.ts`, `authority.ts`, and `tool-handlers.ts`. +- **Consolidate TTL logic** — `validateTTL()` and `parseTTL()` extracted to `types.ts`. Removed duplicates from `capability.ts` and `tool-handlers.ts`. +- **`janee overview` colors** — Capabilities colored by access type: cyan for agent-specific (`allowedAgents`), green for globally open. Warns on exec/proxy mode option mismatches. ## [0.16.0] - 2026-03-17 From 24f723572b5e32b39548675e3fdf860883bf2c14 Mon Sep 17 00:00:00 2001 From: Ross Douglas Date: Tue, 17 Mar 2026 15:00:49 +0200 Subject: [PATCH 3/3] feat: add Firecrawl to built-in service directory Made-with: Cursor --- src/core/directory.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/directory.ts b/src/core/directory.ts index ce81f95..05103e8 100644 --- a/src/core/directory.ts +++ b/src/core/directory.ts @@ -114,6 +114,16 @@ export const serviceDirectory: ServiceTemplate[] = [ docs: 'https://replicate.com/docs/reference/http', tags: ['ai', 'ml', 'models'] }, + + // Web Scraping / Data Extraction + { + name: 'firecrawl', + description: 'Web scraping and crawling API for AI', + baseUrl: 'https://api.firecrawl.dev', + auth: { type: 'bearer', fields: ['key'] }, + docs: 'https://docs.firecrawl.dev/api-reference/v2-introduction', + tags: ['scraping', 'web', 'ai', 'data'] + }, // Crypto Exchanges {