Skip to content
Open
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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <service> # Remove a service
janee remove <service> --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
Expand All @@ -606,8 +608,28 @@ janee logs --json # Output as JSON
janee sessions # List active sessions
janee sessions --json # Output as JSON
janee revoke <id> # Kill a session
janee status # Config and health status
janee whoami --agent <name> # Preview what an agent can access
janee diagnose access <cap> --agent <name> # 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:
Expand Down Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion src/cli/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
59 changes: 54 additions & 5 deletions src/cli/commands/capability.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { validateTTL } from '../../core/types';
import {
cliError,
handleCommandError,
Expand Down Expand Up @@ -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));
}
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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.`);
}
}

Expand All @@ -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,
};
Expand All @@ -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);

Expand Down Expand Up @@ -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) {
Expand All @@ -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);

Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/diagnose.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
11 changes: 2 additions & 9 deletions src/cli/commands/doctor-bundle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { writeFileSync } from 'fs';

import { canAccessCapability } from '../../core/agent-scope';
import {
AuditEvent,
AuditLogger,
Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/new-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ describe('capabilityEditCommand — extended flags', () => {
const cap = captureConsole();
await capabilityEditCommand('stripe_read', {
mode: 'exec',
allowCommands: ['curl'],
timeout: '10000',
json: true,
});
Expand Down
19 changes: 14 additions & 5 deletions src/cli/commands/overview.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -8,9 +20,6 @@
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);

Expand Down Expand Up @@ -62,7 +71,7 @@
await overviewCommand();
cap.restore();
const output = cap.logs.join('\n');
expect(output).toContain('2 services');

Check failure on line 74 in src/cli/commands/overview.test.ts

View workflow job for this annotation

GitHub Actions / test

src/cli/commands/overview.test.ts > overviewCommand > should show service and capability counts

AssertionError: expected '\n \u001b[1m2\u001b[22m services, \u…' to contain '2 services' - Expected + Received - 2 services + + 2 services, 2 capabilities (defaultAccess: open) + + No agent restrictions configured — all capabilities are open. + ❯ src/cli/commands/overview.test.ts:74:20
expect(output).toContain('2 capabilities');
});

Expand All @@ -84,7 +93,7 @@
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');
});

Expand Down Expand Up @@ -201,7 +210,7 @@
await overviewCommand();
cap.restore();
const output = cap.logs.join('\n');
expect(output).toContain('0 services');

Check failure on line 213 in src/cli/commands/overview.test.ts

View workflow job for this annotation

GitHub Actions / test

src/cli/commands/overview.test.ts > overviewCommand > should handle empty config

AssertionError: expected '\n \u001b[1m0\u001b[22m services, \u…' to contain '0 services' - Expected + Received - 0 services + + 0 services, 0 capabilities (defaultAccess: open) + + No capabilities configured. Run janee add <service> to get started. + ❯ src/cli/commands/overview.test.ts:213:20
expect(output).toContain('No capabilities configured');
});
});
Loading
Loading