dsh-cc-loader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lsp.js ADDED
@@ -0,0 +1,84 @@
1
+ // dsh-cc-loader — LSP server config discovery (.lsp.json) — PLACEHOLDER.
2
+ //
3
+ // Claude Code plugins may ship `.lsp.json` at the plugin root:
4
+ // { "<language>": { "command": "typescript-language-server", "args": ["--stdio"],
5
+ // "extensionToLanguage": { "tsx": "typescript" } } }
6
+ //
7
+ // The LSP adapter (dsh-cc-lsp, M3b) is deferred: the ecosystem has ~zero
8
+ // .lsp.json plugins today, and the bridge (mcpls) is only Windows-verified.
9
+ // This module still PARSES and CLASSIFIES the component so the IR inventory
10
+ // is complete and a future adapter has the parse layer ready. Every entry is
11
+ // classified UNSUPPORTED with a clear reason — never silently ignored.
12
+
13
+ import { readFile } from 'node:fs/promises'
14
+ import { join } from 'node:path'
15
+ import { pathExists } from './skills.js'
16
+ import { STATUS } from './classify.js'
17
+
18
+ /**
19
+ * Parse `.lsp.json` text into classified LSP entries. Every entry is
20
+ * UNSUPPORTED (LSP adapter deferred, M3b) — parsed for IR inventory only.
21
+ * @param {string} text - raw .lsp.json text.
22
+ * @param {string} [sourcePath] - for warning messages.
23
+ * @param {(msg: string) => void} [warn]
24
+ * @returns {object[]} entries, each { language, command?, args, extensionToLanguage?, status, reason? }
25
+ */
26
+ export function parseLspText(text, sourcePath = '.lsp.json', warn = () => {}) {
27
+ let parsed
28
+ try {
29
+ parsed = JSON.parse(text)
30
+ } catch (error) {
31
+ warn(`${sourcePath}: invalid JSON: ${error.message}`)
32
+ return []
33
+ }
34
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
35
+ warn(`${sourcePath}: config must be a JSON object`)
36
+ return []
37
+ }
38
+ const servers = []
39
+ for (const [language, raw] of Object.entries(parsed)) {
40
+ const entry = {
41
+ language,
42
+ command: typeof raw?.command === 'string' ? raw.command : undefined,
43
+ args: Array.isArray(raw?.args) ? raw.args.map(String) : [],
44
+ extensionToLanguage: (typeof raw?.extensionToLanguage === 'object' && raw.extensionToLanguage !== null && !Array.isArray(raw.extensionToLanguage))
45
+ ? raw.extensionToLanguage : undefined,
46
+ status: STATUS.UNSUPPORTED,
47
+ reason: 'LSP adapter not implemented (M3b deferred; bridge candidate mcpls)',
48
+ }
49
+ if (entry.command === undefined) {
50
+ warn(`${sourcePath}: language "${language}" missing "command" — recorded with reason`)
51
+ }
52
+ servers.push(entry)
53
+ }
54
+ return servers
55
+ }
56
+
57
+ /** @returns {Promise<{ servers: object[], sources: string[], warnings: string[] }>} */
58
+ export async function discoverLspConfig(dir, opts = {}) {
59
+ const warn = opts.warn ?? (() => {})
60
+ const warnings = []
61
+ const localWarn = (m) => { warnings.push(m); warn(m) }
62
+ const sources = []
63
+ const servers = []
64
+
65
+ const lspPath = join(dir, '.lsp.json')
66
+ if (!(await pathExists(lspPath))) return { servers, sources, warnings }
67
+
68
+ let text
69
+ try {
70
+ text = await readFile(lspPath, 'utf8')
71
+ } catch (error) {
72
+ warnings.push(`cannot read ${lspPath}: ${String(error)}`)
73
+ warn(`cannot read ${lspPath}: ${String(error)}`)
74
+ return { servers, sources, warnings }
75
+ }
76
+ sources.push(lspPath)
77
+
78
+ const entries = parseLspText(text, lspPath, localWarn)
79
+ for (const entry of entries) {
80
+ servers.push(entry)
81
+ localWarn(`LSP language "${entry.language}" (command ${entry.command ?? '?'}) — ${entry.reason}`)
82
+ }
83
+ return { servers, sources, warnings }
84
+ }
@@ -0,0 +1,122 @@
1
+ // Map DSH tool names to Claude Code tool buckets.
2
+ //
3
+ // CC permission rules are written against CC tool names (Bash, Read, Edit,
4
+ // WebFetch, Agent, Skill, MCP servers…). DSH tools use their own names
5
+ // (bash, pwsh, read, write, glob, grep, web_fetch, subagent, terminal_*…).
6
+ // This module owns the mapping in both directions.
7
+ //
8
+ // Verified against DSH's shipped tool inventory (packages/core/tools
9
+ // gen-tool-catalog) and CC permissions reference (code.claude.com/docs/permissions).
10
+
11
+ /** Exact-name mapping: DSH tool name → CC bucket. */
12
+ const BUCKET_BY_TOOL = Object.freeze({
13
+ bash: 'Bash',
14
+ pwsh: 'PowerShell',
15
+ read: 'Read',
16
+ read_image: 'Read',
17
+ write: 'Write',
18
+ edit: 'Edit',
19
+ str_replace_editor: 'Edit',
20
+ glob: 'Glob',
21
+ grep: 'Grep',
22
+ web_fetch: 'WebFetch',
23
+ subagent: 'Agent',
24
+ skill: 'Skill',
25
+ ask_user_question: 'AskUserQuestion',
26
+ })
27
+
28
+ /** Tools CC's Read rules best-effort cover (read-like built-in tools). */
29
+ const READ_COVERED = Object.freeze(['read', 'read_image', 'glob', 'grep'])
30
+ /** Tools CC's Edit rules cover (write/edit-like built-in tools). */
31
+ const EDIT_COVERED = Object.freeze(['edit', 'str_replace_editor', 'write'])
32
+
33
+ /** Bash file commands CC's Read deny rules intercept (cat, head, tail, sed…). */
34
+ const BASH_READ_COMMANDS = new Set([
35
+ 'cat', 'head', 'tail', 'sed', 'less', 'more', 'nl', 'od', 'strings', 'tac', 'wc',
36
+ ])
37
+ /** Bash file commands CC's Edit deny rules intercept (write-capable). */
38
+ const BASH_WRITE_COMMANDS = new Set([
39
+ 'sed', 'awk', 'tee', 'dd', 'cp', 'mv', 'rm', 'truncate', 'touch', 'mkdir', 'ln',
40
+ ])
41
+
42
+ /**
43
+ * Map one DSH tool name to the CC bucket its rules were written for.
44
+ * MCP tools are their own bucket (rule tool names are full `mcp__…` names).
45
+ * @returns {string|null} CC bucket name, or null when unmappable (rules may
46
+ * still match by exact DSH tool name).
47
+ */
48
+ export function ccBucket(toolName) {
49
+ if (typeof toolName !== 'string') return null
50
+ if (toolName.startsWith('mcp__')) return toolName
51
+ if (toolName.startsWith('terminal_')) return 'Bash'
52
+ return BUCKET_BY_TOOL[toolName] ?? null
53
+ }
54
+
55
+ /**
56
+ * Whether a rule's tool target (CC bucket or exact name, may contain `*`)
57
+ * denotes `toolName`. Used for bare rules and tool-glob rules.
58
+ */
59
+ export function ruleTargetsTool(ruleTool, toolName) {
60
+ if (ruleTool.includes('*')) {
61
+ let out = ''
62
+ for (const ch of ruleTool) out += ch === '*' ? '.*' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
63
+ return new RegExp(`^${out}$`).test(toolName)
64
+ }
65
+ if (ruleTool.startsWith('mcp__')) {
66
+ // `mcp__server` matches every tool of that server (`mcp__server__tool`).
67
+ return toolName === ruleTool || toolName.startsWith(`${ruleTool}__`)
68
+ }
69
+ const bucket = ccBucket(toolName)
70
+ return ruleTool === toolName || (bucket !== null && ruleTool === bucket)
71
+ }
72
+
73
+ /** Is `toolName` in the set CC's `Read` rules cover (incl. read-like tools)? */
74
+ export function isReadCoveredTool(toolName) {
75
+ return READ_COVERED.includes(toolName)
76
+ }
77
+
78
+ /** Is `toolName` in the set CC's `Edit` rules cover (incl. write tools)? */
79
+ export function isEditCoveredTool(toolName) {
80
+ return EDIT_COVERED.includes(toolName)
81
+ }
82
+
83
+ /** Is this a Bash file command whose paths CC's Read deny rules intercept? */
84
+ export function isBashReadCommand(command) {
85
+ return BASH_READ_COMMANDS.has(firstWord(command))
86
+ }
87
+
88
+ /** Is this a Bash file command whose paths CC's Edit deny rules intercept? */
89
+ export function isBashWriteCommand(command) {
90
+ return BASH_WRITE_COMMANDS.has(firstWord(command))
91
+ }
92
+
93
+ /**
94
+ * Extract candidate file paths from a Bash command's arguments, skipping
95
+ * flags. Best-effort, mirroring CC's "file commands Claude Code recognizes
96
+ * in Bash".
97
+ */
98
+ export function extractBashPaths(command) {
99
+ if (typeof command !== 'string') return []
100
+ const tokens = command.split(/\s+/).filter((t) => t.length > 0)
101
+ const out = []
102
+ let skipNext = false
103
+ for (const tok of tokens) {
104
+ if (skipNext) { skipNext = false; continue }
105
+ if (tok === '--') continue
106
+ if (tok.startsWith('-') && tok.length > 1) {
107
+ // Flags that take a value consume the next token (`-n 5`); inline
108
+ // values (`--count=5`) already carry theirs.
109
+ if (/^-[a-zA-Z](?!=)/.test(tok) && !tok.includes('=')) skipNext = true
110
+ continue
111
+ }
112
+ out.push(tok)
113
+ }
114
+ return out
115
+ }
116
+
117
+ /** First whitespace-separated word of a command string. */
118
+ function firstWord(command) {
119
+ if (typeof command !== 'string') return ''
120
+ const w = command.trim().split(/\s+/)[0]
121
+ return w ?? ''
122
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,218 @@
1
+ // dsh-cc-loader — MCP server config discovery (.mcp.json / plugin.json inline).
2
+ //
3
+ // Claude Code surfaces MCP servers in three places, all sharing the mcpServers
4
+ // JSON shape used by Cursor and VS Code:
5
+ // 1. Project root .mcp.json — { "mcpServers": { "<name>": {...} } }
6
+ // 2. Plugin root .mcp.json — official plugin form is a BARE server
7
+ // map { "<name>": {...} }; community plugins also wrap in mcpServers
8
+ // 3. plugin.json inline — { ..., "mcpServers": { "<name>": {...} } }
9
+ //
10
+ // This module accepts BOTH the bare-map and the mcpServers-wrapped forms
11
+ // everywhere (detected structurally), so the same code covers project and
12
+ // plugin sources. It is a pure parse layer: nothing is written, env values
13
+ // are NEVER inlined — `${NAME}` placeholders are kept verbatim and the names
14
+ // they reference are recorded (envNames) for the adapter to bind at runtime.
15
+ //
16
+ // Classification (same vocabulary as the rest of the loader):
17
+ // DIRECT — stdio (command) and http (url) transports DSH can bridge
18
+ // UNSUPPORTED — sse / ws transports the DSH MCP client does not speak
19
+ // INVALID — shape errors (skipped with a warning, never fatal)
20
+
21
+ import { readFile } from 'node:fs/promises'
22
+ import { join } from 'node:path'
23
+ import { pathExists } from './skills.js'
24
+ import { STATUS } from './classify.js'
25
+
26
+ /** CC server names are simple identifiers; same bound as dsh-mcp-client. */
27
+ export const VALID_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/
28
+ /** Environment placeholders CC expands: ${NAME}. */
29
+ export const ENV_PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g
30
+
31
+ /**
32
+ * Parse .mcp.json text into the raw servers map, accepting both forms:
33
+ * { "mcpServers": {...} } (wrapped; project-level + community plugins)
34
+ * { "<name>": {...} } (bare; official plugin form)
35
+ * @returns {Record<string, unknown>} servers map
36
+ * @throws {Error} invalid JSON / missing map
37
+ */
38
+ export function parseMcpText(text) {
39
+ let parsed
40
+ try {
41
+ parsed = JSON.parse(text)
42
+ } catch (error) {
43
+ throw new Error(`invalid JSON: ${error.message}`)
44
+ }
45
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
46
+ throw new Error('config must be a JSON object')
47
+ }
48
+ if (typeof parsed.mcpServers === 'object' && parsed.mcpServers !== null && !Array.isArray(parsed.mcpServers)) {
49
+ return parsed.mcpServers
50
+ }
51
+ // Bare map form: every value is a server config object (no "mcpServers" key).
52
+ const values = Object.values(parsed)
53
+ if (values.some((v) => typeof v !== 'object' || v === null || Array.isArray(v))) {
54
+ throw new Error('expected "mcpServers" object or a bare server map')
55
+ }
56
+ return parsed
57
+ }
58
+
59
+ /**
60
+ * Normalize one servers map into ordered, classified server entries.
61
+ * Invalid entries are skipped with warnings (never thrown).
62
+ * @param {Record<string, unknown>} servers - raw map from parseMcpText
63
+ * @param {object} [opts] - { pluginName?, warn? }
64
+ * @returns {object[]} entries, each:
65
+ * { serverName, pluginName?, transport: 'stdio'|'http'|'sse'|'ws',
66
+ * command?, args?, cwd?, env (placeholders kept), envNames: string[],
67
+ * url?, headers?, toolCallTimeoutMs?, idleTimeoutMs?, override?,
68
+ * status, reason? }
69
+ */
70
+ export function serverEntries(servers, opts = {}) {
71
+ const warn = opts.warn ?? (() => {})
72
+ const entries = []
73
+ for (const [serverName, raw] of Object.entries(servers)) {
74
+ const base = { serverName, pluginName: opts.pluginName }
75
+ if (!VALID_SERVER_NAME.test(serverName)) {
76
+ warn(`MCP server name ${JSON.stringify(serverName)} invalid (need 1-32 of [A-Za-z0-9_-]) — skipped`)
77
+ continue
78
+ }
79
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
80
+ warn(`MCP server ${serverName}: config must be an object — skipped`)
81
+ continue
82
+ }
83
+ const hasCommand = typeof raw.command === 'string' && raw.command.length > 0
84
+ const hasUrl = typeof raw.url === 'string' && raw.url.length > 0
85
+ if (hasCommand && hasUrl) {
86
+ warn(`MCP server ${serverName}: provide exactly one of "command" (stdio) or "url" (remote) — skipped`)
87
+ continue
88
+ }
89
+ if (!hasCommand && !hasUrl) {
90
+ warn(`MCP server ${serverName}: missing both "command" and "url" — skipped`)
91
+ continue
92
+ }
93
+ const type = typeof raw.type === 'string' ? raw.type.toLowerCase() : undefined
94
+ let transport
95
+ if (hasCommand) transport = 'stdio'
96
+ else if (type === 'sse') transport = 'sse'
97
+ else if (type === 'ws' || type === 'websocket') transport = 'ws'
98
+ else transport = 'http' // url without type, or type http → streamable-http
99
+
100
+ // env: keep placeholders verbatim, record referenced names only.
101
+ const env = {}
102
+ const envNames = []
103
+ if (raw.env !== undefined) {
104
+ if (typeof raw.env !== 'object' || raw.env === null || Array.isArray(raw.env)) {
105
+ warn(`MCP server ${serverName}: env must be an object — skipped`)
106
+ continue
107
+ }
108
+ for (const [k, v] of Object.entries(raw.env)) {
109
+ const value = String(v)
110
+ env[k] = value
111
+ for (const match of value.matchAll(ENV_PLACEHOLDER)) {
112
+ if (!envNames.includes(match[1])) envNames.push(match[1])
113
+ }
114
+ }
115
+ }
116
+ const headers = {}
117
+ if (raw.headers !== undefined) {
118
+ if (typeof raw.headers !== 'object' || raw.headers === null || Array.isArray(raw.headers)) {
119
+ warn(`MCP server ${serverName}: headers must be an object — skipped`)
120
+ continue
121
+ }
122
+ for (const [k, v] of Object.entries(raw.headers)) headers[k] = String(v)
123
+ }
124
+
125
+ const entry = {
126
+ ...base,
127
+ transport,
128
+ command: hasCommand ? raw.command : undefined,
129
+ args: Array.isArray(raw.args) ? raw.args.map(String) : [],
130
+ cwd: typeof raw.cwd === 'string' && raw.cwd.length > 0 ? raw.cwd : undefined,
131
+ env,
132
+ envNames,
133
+ url: hasUrl ? raw.url : undefined,
134
+ headers,
135
+ toolCallTimeoutMs: Number.isFinite(raw.toolCallTimeoutMs) ? raw.toolCallTimeoutMs : undefined,
136
+ idleTimeoutMs: Number.isFinite(raw.idleTimeoutMs) ? raw.idleTimeoutMs : undefined,
137
+ override: raw.override === true,
138
+ status: transport === 'stdio' || transport === 'http' ? STATUS.DIRECT : STATUS.UNSUPPORTED,
139
+ reason: transport === 'stdio' || transport === 'http' ? undefined
140
+ : `transport "${transport}" is not supported by the DSH MCP client (stdio/streamable-http only)`,
141
+ }
142
+ entries.push(entry)
143
+ }
144
+ return entries
145
+ }
146
+
147
+ /**
148
+ * Discover MCP config from one directory: `.mcp.json` first, then plugin.json
149
+ * inline `mcpServers` (unless opts.includePluginJson === false). Both parse
150
+ * under the same dual-form parser.
151
+ * @param {string} dir - project root or plugin root
152
+ * @param {object} [opts] - { pluginName?, includePluginJson?, warn? }
153
+ * @returns {Promise<{ servers: object[], sources: string[], warnings: string[] }>}
154
+ */
155
+ export async function discoverMcpConfig(dir, opts = {}) {
156
+ const warn = opts.warn ?? (() => {})
157
+ const includePluginJson = opts.includePluginJson !== false
158
+ const warnings = []
159
+ const localWarn = (m) => { warnings.push(m); warn(m) }
160
+ const sources = []
161
+ const servers = []
162
+
163
+ const mcpPath = join(dir, '.mcp.json')
164
+ if (await pathExists(mcpPath)) {
165
+ let text
166
+ try {
167
+ text = await readFile(mcpPath, 'utf8')
168
+ } catch (error) {
169
+ warnings.push(`cannot read ${mcpPath}: ${String(error)}`)
170
+ warn(`cannot read ${mcpPath}: ${String(error)}`)
171
+ }
172
+ if (text !== undefined) {
173
+ try {
174
+ const map = parseMcpText(text)
175
+ servers.push(...serverEntries(map, { pluginName: opts.pluginName, warn: localWarn }))
176
+ sources.push(mcpPath)
177
+ } catch (error) {
178
+ warnings.push(`${mcpPath}: ${error.message}`)
179
+ warn(`${mcpPath}: ${error.message}`)
180
+ }
181
+ }
182
+ }
183
+
184
+ const pluginJsonPath = join(dir, 'plugin.json')
185
+ if (includePluginJson && await pathExists(pluginJsonPath)) {
186
+ let text
187
+ try {
188
+ text = await readFile(pluginJsonPath, 'utf8')
189
+ } catch (error) {
190
+ warnings.push(`cannot read ${pluginJsonPath}: ${String(error)}`)
191
+ warn(`cannot read ${pluginJsonPath}: ${String(error)}`)
192
+ }
193
+ if (text !== undefined) {
194
+ let parsed
195
+ try {
196
+ parsed = JSON.parse(text)
197
+ } catch (error) {
198
+ warnings.push(`${pluginJsonPath}: invalid JSON: ${error.message}`)
199
+ warn(`${pluginJsonPath}: invalid JSON: ${error.message}`)
200
+ }
201
+ if (parsed && typeof parsed.mcpServers === 'object' && parsed.mcpServers !== null && !Array.isArray(parsed.mcpServers)) {
202
+ servers.push(...serverEntries(parsed.mcpServers, { pluginName: opts.pluginName, warn: localWarn }))
203
+ sources.push(pluginJsonPath)
204
+ }
205
+ }
206
+ }
207
+
208
+ return { servers, sources, warnings }
209
+ }
210
+
211
+ /**
212
+ * Project-level MCP: `<projectRoot>/.mcp.json` (plugin.json is a
213
+ * plugin-package concern, excluded here).
214
+ * @returns {Promise<{ servers: object[], sources: string[], warnings: string[] }>}
215
+ */
216
+ export async function discoverProjectMcp(projectRoot, opts = {}) {
217
+ return discoverMcpConfig(projectRoot, { ...opts, includePluginJson: false })
218
+ }
@@ -0,0 +1,98 @@
1
+ // CC permission rule syntax parser: `Tool` or `Tool(specifier)`.
2
+ //
3
+ // Verified against code.claude.com/docs/permissions ("Permission rule syntax",
4
+ // "Match by input parameter", "Tool name wildcards", per-tool rules).
5
+ // A rule is parsed into a structured object with a `kind`:
6
+ // bare — `Bash` / `Bash(*)` / any tool without specifier
7
+ // tool-glob — glob in tool-name position (`mcp__*`, `B*`…)
8
+ // command — Bash/PowerShell command pattern
9
+ // path — Read/Edit/Cd gitignore path pattern
10
+ // domain — WebFetch `domain:…`
11
+ // agent-name — `Agent(Name)` subagent restriction
12
+ // param — `Tool(param:value)` top-level input-parameter match
13
+ // unknown — spec we cannot faithfully evaluate (reported, not enforced)
14
+
15
+ import { compileCommandPattern, compilePathPattern, compileDomainPattern } from './patterns.js'
16
+
17
+ const TOOL_RE = /^([A-Za-z_*][A-Za-z0-9_*]*(?:__[A-Za-z0-9_*:-]+)*)(?:\(([^)]*)\))?$/
18
+
19
+ /** Tool input fields that cannot be matched via `Tool(param:value)` (CC warns and ignores). */
20
+ const PRIMARY_FIELDS = new Set(['command', 'file_path', 'path', 'url', 'notebook_path'])
21
+
22
+ /**
23
+ * Parse one CC rule string.
24
+ * @param {string} raw
25
+ * @param {{ scope?: string, path?: string }} [meta] - source scope/file for reporting.
26
+ * @returns {object|null} structured rule, or null when unparseable.
27
+ */
28
+ export function parseRule(raw, meta = {}) {
29
+ const text = String(raw).trim()
30
+ const m = TOOL_RE.exec(text)
31
+ if (!m) return { raw: text, tool: null, spec: null, kind: 'unknown', invalid: true, reason: `unparseable rule "${text}"`, ...meta }
32
+ const tool = m[1]
33
+ const spec = m[2] === undefined ? null : m[2].trim()
34
+ const base = { raw: text, tool, spec, scope: meta.scope, path: meta.path }
35
+
36
+ // Tool-name glob (`*`, `mcp__*`, `mcp__server__*`, `B*`).
37
+ if (tool.includes('*')) return { ...base, kind: 'tool-glob', glob: compileToolGlob(tool) }
38
+
39
+ if (spec === null || spec === '') return { ...base, kind: 'bare' }
40
+
41
+ // `Tool(*)` on Bash/PowerShell is equivalent to a bare rule.
42
+ if ((tool === 'Bash' || tool === 'PowerShell') && (spec === '*' || spec === ':*')) {
43
+ return { ...base, kind: 'bare' }
44
+ }
45
+
46
+ // Tool-specific specifiers FIRST so they win over the generic param:value
47
+ // shape: `Bash(ls:*)` is a command pattern (the `:*` trailing suffix), and
48
+ // `WebFetch(domain:…)` is a domain rule — neither is a param match.
49
+ if (tool === 'Bash' || tool === 'PowerShell') {
50
+ if (spec.endsWith(':*')) {
51
+ return { ...base, kind: 'command', command: compileCommandPattern(spec, { icase: tool === 'PowerShell' }), icase: tool === 'PowerShell' }
52
+ }
53
+ }
54
+ if (tool === 'WebFetch' && spec.startsWith('domain:')) {
55
+ return { ...base, kind: 'domain', domain: spec.slice(7), domainRe: compileDomainPattern(spec.slice(7)) }
56
+ }
57
+
58
+ // param:value — before remaining tool-specific handling so `Agent(model:opus)`
59
+ // and `Bash(run_in_background:true)` parse as param rules.
60
+ const pm = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(spec)
61
+ if (pm) {
62
+ const param = pm[1]
63
+ const value = pm[2]
64
+ if (PRIMARY_FIELDS.has(param)) {
65
+ return { ...base, kind: 'unknown', invalid: true, reason: `cannot match primary field via param syntax (${tool}(${param}:…))` }
66
+ }
67
+ return { ...base, kind: 'param', param, value }
68
+ }
69
+
70
+ switch (tool) {
71
+ case 'WebFetch':
72
+ return { ...base, kind: 'unknown', invalid: true, reason: `WebFetch rules require a domain: specifier` }
73
+ case 'Agent':
74
+ // Agent(AgentName) restricts a subagent by name.
75
+ return { ...base, kind: 'agent-name', name: spec }
76
+ case 'Skill':
77
+ // Skill(name) restricts a skill invocation by name.
78
+ return { ...base, kind: 'skill-name', name: spec }
79
+ case 'Read':
80
+ case 'Edit':
81
+ case 'Cd':
82
+ return { ...base, kind: 'path', pathPattern: compilePathPattern(spec) }
83
+ case 'Bash':
84
+ case 'PowerShell':
85
+ return { ...base, kind: 'command', command: compileCommandPattern(spec, { icase: tool === 'PowerShell' }), icase: tool === 'PowerShell' }
86
+ default:
87
+ // A spec on an unknown tool: CC only supports param:value there (handled
88
+ // above) or tool-specific syntax we do not know — report, don't enforce.
89
+ return { ...base, kind: 'unknown', invalid: true, reason: `unsupported specifier on tool ${tool}` }
90
+ }
91
+ }
92
+
93
+ function compileToolGlob(tool) {
94
+ // Full-name match; `*` spans everything (CC: pattern must match the full tool name).
95
+ let out = ''
96
+ for (const ch of tool) out += ch === '*' ? '.*' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
97
+ return new RegExp(`^${out}$`)
98
+ }
@@ -0,0 +1,121 @@
1
+ // Glob pattern compilation for CC permission rules.
2
+ //
3
+ // Three pattern dialects:
4
+ // - Command (Bash/PowerShell): `*` spans any chars incl. spaces; a trailing
5
+ // `:*` is equivalent to a trailing ` *`; a trailing ` *` (space-star)
6
+ // requires the prefix to be followed by a space or end-of-string.
7
+ // - Path (Read/Edit/Cd): gitignore-style. `**` spans directories, `*` spans
8
+ // one path segment, `?` one char, `[...]` char classes. Anchors: `//` =
9
+ // filesystem root, `~/` = home, `/` = relative to the settings source,
10
+ // `./` or bare = relative to cwd.
11
+ // - Domain (WebFetch): `*` matches text between dots (a leading `*.` spans
12
+ // any subdomain depth; a bare `*` matches everything).
13
+
14
+ /** Escape a literal string for use inside a RegExp. */
15
+ export function escapeRegExp(s) {
16
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
17
+ }
18
+
19
+ /**
20
+ * Translate a glob to a regex body (no anchors).
21
+ * @param {string} pattern
22
+ * @param {{ segment?: string|null }} [opts] - segment separator: '/' for
23
+ * paths, '.' for domains, null for commands (`*` then spans everything).
24
+ */
25
+ export function globToRegexBody(pattern, { segment = '/' } = {}) {
26
+ let out = ''
27
+ let i = 0
28
+ const seg = segment === null ? null : segment
29
+ while (i < pattern.length) {
30
+ const ch = pattern[i]
31
+ if (ch === '*') {
32
+ if (pattern[i + 1] === '*') { out += '.*'; i += 2; continue }
33
+ out += seg === null ? '.*' : `[^${seg}]*`
34
+ i++
35
+ } else if (ch === '?') {
36
+ out += seg === null ? '.' : `[^${seg}]`
37
+ i++
38
+ } else if (ch === '[') {
39
+ const close = pattern.indexOf(']', i + 1)
40
+ if (close > 0) { out += pattern.slice(i, close + 1); i = close + 1 }
41
+ else { out += '\\['; i++ }
42
+ } else {
43
+ out += escapeRegExp(ch)
44
+ i++
45
+ }
46
+ }
47
+ return out
48
+ }
49
+
50
+ /**
51
+ * Compile a Bash/PowerShell command pattern.
52
+ *
53
+ * CC semantics for a trailing ` *` (space-star, or the `:*` suffix): it
54
+ * matches commands STARTING WITH the prefix — with or without following
55
+ * arguments — while still enforcing a word boundary (a rule `ls *` matches
56
+ * `ls`, `ls -la`, but not `lsof`).
57
+ * @param {string} pattern
58
+ * @param {{ icase?: boolean }} [opts] - PowerShell matching is case-insensitive.
59
+ */
60
+ export function compileCommandPattern(pattern, { icase = false } = {}) {
61
+ let p = pattern
62
+ if (p.endsWith(':*')) p = p.slice(0, -2) + ' *'
63
+ let trailing = false
64
+ if (p.endsWith(' *')) { trailing = true; p = p.slice(0, -2) }
65
+ const body = globToRegexBody(p, { segment: null })
66
+ const re = trailing
67
+ ? new RegExp(`^${body}(?: .*)?$`, icase ? 'i' : undefined)
68
+ : new RegExp(`^${body}$`, icase ? 'i' : undefined)
69
+ return re
70
+ }
71
+
72
+ /**
73
+ * Compile a Read/Edit/Cd path pattern into its anchored shape.
74
+ * @returns {{ kind: 'absolute'|'home'|'source'|'cwd', re: RegExp,
75
+ * singleSegment: boolean, prefixAny: boolean }}
76
+ */
77
+ export function compilePathPattern(pattern) {
78
+ let p = pattern
79
+ let kind = 'cwd'
80
+ if (p.startsWith('//')) { kind = 'absolute'; p = p.slice(2) }
81
+ else if (p.startsWith('~/')) { kind = 'home'; p = p.slice(2) }
82
+ else if (p.startsWith('/')) { kind = 'source'; p = p.slice(1) }
83
+ else if (p.startsWith('./')) { kind = 'cwd'; p = p.slice(2) }
84
+ let trailingAny = false
85
+ if (p.endsWith('/**')) { trailingAny = true; p = p.slice(0, -3) }
86
+ let prefixAny = false
87
+ if (p.startsWith('**/')) { prefixAny = true; p = p.slice(3) }
88
+ const singleSegment = kind === 'cwd' && !p.includes('/')
89
+ const body = globToRegexBody(p, { segment: '/' })
90
+ // Absolute patterns match the full POSIX path, so keep the leading slash.
91
+ const head = kind === 'absolute' ? '/' : ''
92
+ const re = trailingAny ? new RegExp(`^${head}${body}(?:/.*)?$`) : new RegExp(`^${head}${body}$`)
93
+ return { kind, re, singleSegment, prefixAny }
94
+ }
95
+
96
+ /**
97
+ * Compile a WebFetch domain pattern.
98
+ * @param {string} domain - the text after `domain:`.
99
+ */
100
+ export function compileDomainPattern(domain) {
101
+ const d = String(domain).trim().toLowerCase().replace(/\.$/, '')
102
+ if (d === '*') return /.*/
103
+ if (d.startsWith('*.')) {
104
+ const rest = escapeRegExp(d.slice(2))
105
+ // any subdomain at any depth, but not the bare domain
106
+ return new RegExp(`^(.+\\.)+${rest}$`)
107
+ }
108
+ const body = globToRegexBody(d, { segment: '.' })
109
+ return new RegExp(`^${body}$`)
110
+ }
111
+
112
+ /**
113
+ * Normalize a Windows path to POSIX form for path-rule matching
114
+ * (`C:\Users\alice` → `/c/Users/alice`), mirroring CC's Windows behavior.
115
+ */
116
+ export function winPathToPosix(p) {
117
+ if (typeof p !== 'string') return p
118
+ if (!/^[A-Za-z]:[\\/]/.test(p)) return p.replace(/\\/g, '/')
119
+ const drive = p[0].toLowerCase()
120
+ return `/${drive}${p.slice(2).replace(/\\/g, '/')}`
121
+ }