Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c7d2325
feat(core): capability-gated tool surface (#829)
shaun0927 May 12, 2026
df48e08
fix(core): codex P1 + gemini findings on capability-gated tool surface
shaun0927 May 12, 2026
9397695
Keep capability filters composable on current develop
shaun0927 May 12, 2026
0611368
Keep capability-gate tests aligned with current surface
shaun0927 May 12, 2026
d4ef394
Keep capability gate branch mergeable
shaun0927 May 12, 2026
51f1ea6
Align capability gate CI tool count
shaun0927 May 12, 2026
b8890f8
Normalize console fixture in capability gate PR
shaun0927 May 12, 2026
27ccd34
Refresh branch fixtures after s2c merge
shaun0927 May 12, 2026
4eb8baa
Enforce capability filtering after develop merge
shaun0927 May 13, 2026
a98b26e
Keep capability gates aligned with latest tools
shaun0927 May 13, 2026
c291300
Let schema introspection flush before exiting
shaun0927 May 13, 2026
a576942
Ignore unrelated stdout noise in admin JSON tests
shaun0927 May 13, 2026
6455f65
Carry capability gates onto snapshot refs baseline
shaun0927 May 13, 2026
3755d70
Carry capability gates onto output metrics baseline
shaun0927 May 13, 2026
42c67a5
Merge remote-tracking branch 'origin/develop' into feat/829-capabilit…
shaun0927 May 13, 2026
d53b70c
Merge develop into feat/829-capability-gate
shaun0927 May 13, 2026
3e8088a
Merge develop into feat/829-capability-gate
shaun0927 May 13, 2026
321d530
fix(924): adopt develop package.json/lock.json so cheerio/turndown de…
shaun0927 May 13, 2026
307fa4a
fix(capability-gate): annotate develop-era tools in TOOL_CAPABILITY_MAP
shaun0927 May 13, 2026
dc352da
Merge remote-tracking branch 'origin/feat/829-capability-gate' into a…
shaun0927 May 13, 2026
78d96bb
fix(924): add capability tags for new tools from develop merge
shaun0927 May 13, 2026
ccbfd77
fix(924): remove duplicate TOOL_CAPABILITY_MAP entries
shaun0927 May 13, 2026
f42ab9f
fix(924): regenerate v1.11 snapshot with develop-era tools
shaun0927 May 13, 2026
cbe193d
Merge remote-tracking branch 'origin/develop' into temp-924
shaun0927 May 13, 2026
ba07054
fix(924): add oc_recording_status capability + regen snapshot
shaun0927 May 13, 2026
e2800b6
Merge develop into feat/829-capability-gate
shaun0927 May 13, 2026
4c28d0a
Merge develop into feat/829-capability-gate
shaun0927 May 13, 2026
577b5da
Merge remote-tracking branch 'origin/feat/829-capability-gate' into a…
shaun0927 May 13, 2026
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
157 changes: 157 additions & 0 deletions scripts/lint-tools-capabilities.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env node
'use strict';

/**
* lint:tools-capabilities (#829)
*
* Verifies that every tool registered via registerAllTools() has an entry in
* TOOL_CAPABILITY_MAP (src/tools/index.ts) and therefore gets a `capability`
* field on its MCPToolDefinition.
*
* Fails with exit code 1 if any registered tool name is missing from the map.
*
* Usage: node scripts/lint-tools-capabilities.js
* npm run lint:tools-capabilities
*/

const path = require('path');
const fs = require('fs');

const ROOT = path.join(__dirname, '..');
const TOOLS_INDEX = path.join(ROOT, 'src', 'tools', 'index.ts');

// ---------------------------------------------------------------------------
// Parse TOOL_CAPABILITY_MAP from the TypeScript source via simple regex.
// We do not compile the TS here — the map is a plain object literal with
// string keys so a regex scan is reliable and fast.
// ---------------------------------------------------------------------------

function parseCapabilityMap(src) {
// Match the TOOL_CAPABILITY_MAP object literal
const mapMatch = src.match(/export const TOOL_CAPABILITY_MAP[^=]*=\s*\{([^}]+)\}/s);
if (!mapMatch) {
throw new Error('Could not find TOOL_CAPABILITY_MAP in src/tools/index.ts');
}

const body = mapMatch[1];
const entries = {};
// Match lines like: navigate: 'core',
const lineRe = /^\s+(\w+):\s*'(\w+)',?/gm;
let m;
while ((m = lineRe.exec(body)) !== null) {
entries[m[1]] = m[2];
}
return entries;
}

// ---------------------------------------------------------------------------
// Parse the list of tools that are actually registered by scanning for
// server.registerTool( calls in all src/**/*.ts production sources.
//
// Two call patterns exist:
// 1. server.registerTool('literal_name', ...)
// 2. server.registerTool(someVar.name, ...) — name defined elsewhere as name: 'literal'
//
// For pattern 2 we also scan the file for MCPToolDefinition `name:` fields.
// ---------------------------------------------------------------------------

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Include pilot tool sources in capability lint scan

The linter only scans src/tools/*.ts for registerTool(...) calls, so tools registered from outside that directory (notably oc_proxy_hook from src/pilot/proxy/hook) are invisible to the check. If such a tool is omitted from TOOL_CAPABILITY_MAP, CI will still pass and the tool will silently default to core, breaking intended capability-group filtering for pilot deployments.

Useful? React with 👍 / 👎.

function collectRegisteredSourceFiles(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'index.ts') continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scan index.ts files when linting capability coverage

Do not skip every index.ts here: this creates false negatives in the new capability lint. The current walk ignores files like src/pilot/dynamic-skills/index.ts, which already calls state.server.registerTool(...), so missing capability tagging in those registrations can pass CI undetected and silently fall back to 'core' behavior instead of the intended group filtering.

Useful? React with 👍 / 👎.

const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectRegisteredSourceFiles(full, out);
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
const src = fs.readFileSync(full, 'utf8');
if (/\.registerTool\(/.test(src)) {
out.push({ file: full, src });
}
}
}
return out;
}

function collectRegisteredToolNames() {
const files = collectRegisteredSourceFiles(path.join(ROOT, 'src'));

const names = new Set();

// Pattern 1: .registerTool('literal_name', ...)
const literalRe = /\.registerTool\(\s*['"](\w+)['"]/g;

// Pattern 2: name: 'tool_name' (inside MCPToolDefinition objects)
// We capture all name: 'value' assignments in files that also call registerTool
const nameFieldRe = /^\s+name:\s*['"](\w+)['"]/gm;

for (const { src } of files) {

// Pattern 1: direct `name: 'value'` inside a registerTool() definition.
literalRe.lastIndex = 0;
let m;
while ((m = literalRe.exec(src)) !== null) {
names.add(m[1]);
}

// Pattern 2: if file uses .registerTool(varExpr.name, ...) scan for name: 'value'
if (/\.registerTool\(\s*\w+\.\w+/.test(src)) {
nameFieldRe.lastIndex = 0;
while ((m = nameFieldRe.exec(src)) !== null) {
names.add(m[1]);
}
}
}

return names;
}

function main() {
let indexSrc;
try {
indexSrc = fs.readFileSync(TOOLS_INDEX, 'utf8');
} catch (err) {
console.error(`lint:tools-capabilities: cannot read ${TOOLS_INDEX}: ${err.message}`);
process.exit(1);
}

let capMap;
try {
capMap = parseCapabilityMap(indexSrc);
} catch (err) {
console.error(`lint:tools-capabilities: ${err.message}`);
process.exit(1);
}

const registered = collectRegisteredToolNames();

const missing = [];
for (const name of [...registered].sort()) {
if (!(name in capMap)) {
missing.push(name);
}
}

if (missing.length > 0) {
console.error('lint:tools-capabilities FAILED');
console.error(`The following ${missing.length} tool(s) are registered but have no entry in TOOL_CAPABILITY_MAP:`);
for (const name of missing) {
console.error(` - ${name}`);
}
console.error('\nAdd each tool to TOOL_CAPABILITY_MAP in src/tools/index.ts.');
process.exit(1);
}

const mapKeys = Object.keys(capMap).sort();
const unregistered = mapKeys.filter(k => k !== 'expand_tools' && !registered.has(k));
if (unregistered.length > 0) {
console.warn('lint:tools-capabilities WARNING: TOOL_CAPABILITY_MAP has entries for unregistered tools:');
for (const name of unregistered) {
console.warn(` - ${name}`);
}
}

const total = registered.size;
console.log(`lint:tools-capabilities OK — ${total} tool(s) all have capability tags (${Object.keys(capMap).length} entries in map).`);
process.exit(0);
}

main();
42 changes: 39 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { Command } from 'commander';
import { getMCPServer, setMCPServerOptions } from './mcp-server';
import { TOOL_CAPABILITIES, type ToolCapability } from './types/mcp';
import { registerAllTools } from './tools';
import { createTransport } from './transports/index';
import { getGlobalConfig, setGlobalConfig } from './config/global';
Expand Down Expand Up @@ -98,11 +99,13 @@ program
.option('--transport <mode>', 'Transport mode: stdio, http, or both (default: stdio)')
.option('--idle-timeout <duration>', 'Self-exit (code 0) after idle window with zero sessions. Format: <number>(ms|s|m|h), e.g. 30m, 90s, 500ms. Bare numbers are rejected. Also: OPENCHROME_IDLE_TIMEOUT_MS env var (integer ms). Default: disabled.')
.option('--pilot', 'Enable experimental pilot tier (see docs/roadmap/portability-harness-contract.md). Off by default; lazy-loads src/pilot/ modules when set. Also: OPENCHROME_PILOT=1 env var.')
.option('--tools-only <csv>', 'Expose only tools belonging to the specified capability groups (comma-separated). Valid values: core,crawl,recording,workflow,storage,profile,totp,pilot. Default: all groups exposed.')
.option('--disable-tools <csv>', 'Remove tools belonging to the specified capability groups (comma-separated). Valid values: core,crawl,recording,workflow,storage,profile,totp,pilot.')
Comment on lines +102 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor capability flags in introspection mode

The new --tools-only/--disable-tools options are advertised globally, but when --introspect-tools-list is used the handler returns before capability parsing and before setMCPServerOptions(mcpOptions) runs, so introspection always emits the full unfiltered manifest. This makes automation that relies on --introspect-tools-list to verify filtered tool surfaces report incorrect results (e.g. --introspect-tools-list --tools-only=core still lists workflow/crawl tools).

Useful? React with 👍 / 👎.

.option('--introspect-tools-list', 'Print tools/list as compact JSON to stdout and exit (no Chrome/CDP startup). Used by lint-tool-schemas.mjs.')
.option('--auto-connect [userDataDir]', 'Attach to a Chrome you started yourself by reading <userDataDir>/DevToolsActivePort (#849). When omitted, uses the platform-default Chrome user-data dir. Also: OPENCHROME_AUTO_CONNECT=<dir> env var. Implies --launch-mode=attach.')
.option('--launch-mode <mode>', 'Chrome launch mode: auto | attach | isolated (#659). Also: OPENCHROME_LAUNCH_MODE env var.')
.option('--secrets <path>', 'Load a dotenv-format secrets file (KEY=value per line). Tokens "${SECRET:NAME}" in tool arguments are substituted to the real value at MCP request deserialization; the same values are redacted from every LLM-visible artifact (responses, trace, skill records, journal). Default: no secrets loaded. P3: no OS keychain integration.')
.action(async (options: { port: string; autoLaunch?: boolean; userDataDir?: string; profileDirectory?: string; chromeBinary?: string; headlessShell?: boolean; headless?: boolean; visible?: boolean; windowSize?: string; windowPosition?: string; windowBounds?: string; startMaximized?: boolean; restartChrome?: boolean; hybrid?: boolean; lpPort?: string; blockedDomains?: string; auditLog?: boolean; sanitizeContent?: boolean; allTools?: boolean; serverMode?: boolean; http?: string | boolean; authToken?: string; transport?: string; idleTimeout?: string; allowUnauthenticatedHttp?: boolean; pilot?: boolean; introspectToolsList?: boolean; autoConnect?: string | boolean; launchMode?: string; secrets?: string }) => {
.action(async (options: { port: string; autoLaunch?: boolean; userDataDir?: string; profileDirectory?: string; chromeBinary?: string; headlessShell?: boolean; headless?: boolean; visible?: boolean; windowSize?: string; windowPosition?: string; windowBounds?: string; startMaximized?: boolean; restartChrome?: boolean; hybrid?: boolean; lpPort?: string; blockedDomains?: string; auditLog?: boolean; sanitizeContent?: boolean; allTools?: boolean; serverMode?: boolean; http?: string | boolean; authToken?: string; transport?: string; idleTimeout?: string; allowUnauthenticatedHttp?: boolean; pilot?: boolean; toolsOnly?: string; disableTools?: string; introspectToolsList?: boolean; autoConnect?: string | boolean; launchMode?: string; secrets?: string }) => {
// --introspect-tools-list: print tools/list JSON and exit, NO Chrome/CDP/transport startup.
if (options.introspectToolsList) {
const { MCPServer } = await import('./mcp-server');
Expand All @@ -121,7 +124,9 @@ program
return;
}


let port = parseInt(options.port, 10);

let autoLaunch = options.autoLaunch || false;

// ─── --auto-connect (#849) ──────────────────────────────────────────
Expand Down Expand Up @@ -373,16 +378,47 @@ program
console.error('[openchrome] Content sanitization: disabled');
}

const mcpOptions: Parameters<typeof setMCPServerOptions>[0] = {};

// Tool tier configuration
const envTier = parseInt(process.env.OPENCHROME_TOOL_TIER || '', 10);
if (options.allTools || envTier >= 3) {
setMCPServerOptions({ initialToolTier: 3 as ToolTier });
mcpOptions.initialToolTier = 3 as ToolTier;
console.error('[openchrome] All tools exposed from startup');
} else if (envTier === 2) {
setMCPServerOptions({ initialToolTier: 2 as ToolTier });
mcpOptions.initialToolTier = 2 as ToolTier;
console.error('[openchrome] Tier 2 tools exposed from startup');
}

// Capability filter configuration (#829)
const allCapabilities: readonly ToolCapability[] = TOOL_CAPABILITIES;
if (options.toolsOnly && options.disableTools) {
console.error('[openchrome] Error: --tools-only and --disable-tools are mutually exclusive');
process.exit(2);
}
if (options.toolsOnly) {
const requested = options.toolsOnly.split(',').map(s => s.trim()).filter(Boolean) as ToolCapability[];
const invalid = requested.filter(c => !allCapabilities.includes(c));
if (invalid.length > 0) {
console.error(`[openchrome] Error: unknown capability group(s): ${invalid.join(', ')}. Valid: ${allCapabilities.join(', ')}`);
process.exit(2);
}
mcpOptions.capabilityFilter = new Set(requested);
console.error(`[openchrome] Capability filter (tools-only): ${requested.join(', ')}`);
Comment on lines +406 to +407
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Raise tier when capability filter excludes core tools

When --tools-only/--disable-tools sets a non-core capability filter, this code only sets capabilityFilter and leaves initialToolTier unset, so known progressive-disclosure clients (e.g. Claude/Cursor) stay at tier 1; combined with handleToolsList only injecting expand_tools when core is allowed, combinations like --tools-only=workflow or --disable-tools=core can produce an empty tools/list even though allowed tools exist at higher tiers (workflow/recording/crawl/totp), making those valid flag combinations effectively unusable unless users also pass --all-tools or know hidden tool names.

Useful? React with 👍 / 👎.

} else if (options.disableTools) {
const disabled = options.disableTools.split(',').map(s => s.trim()).filter(Boolean) as ToolCapability[];
const invalid = disabled.filter(c => !allCapabilities.includes(c));
if (invalid.length > 0) {
console.error(`[openchrome] Error: unknown capability group(s): ${invalid.join(', ')}. Valid: ${allCapabilities.join(', ')}`);
process.exit(2);
}
const allowed = allCapabilities.filter(c => !disabled.includes(c));
mcpOptions.capabilityFilter = new Set(allowed);
console.error(`[openchrome] Capability filter (disable-tools): disabled=${disabled.join(', ')}`);
}

setMCPServerOptions(mcpOptions);

// Set infinite reconnection for HTTP daemon mode BEFORE creating CDPClient singleton.
// getMCPServer() → SessionManager → getCDPClient() reads this env var at construction.
// Resolve transport mode: --transport flag takes precedence over --http flag
Expand Down
Loading
Loading