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/README.md +28 -0
- package/package.json +30 -0
- package/src/agents.js +296 -0
- package/src/classify.js +465 -0
- package/src/index.js +41 -0
- package/src/load.js +174 -0
- package/src/lsp.js +84 -0
- package/src/map-tools.js +122 -0
- package/src/mcp.js +218 -0
- package/src/parse-rule.js +98 -0
- package/src/patterns.js +121 -0
- package/src/plugin.js +750 -0
- package/src/settings.js +123 -0
- package/src/skills.js +250 -0
package/src/classify.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// Evaluation engine: CC deny → ask → allow folding over one tool call,
|
|
2
|
+
// plus tool-removal detection and component classification.
|
|
3
|
+
//
|
|
4
|
+
// Verified against code.claude.com/docs/permissions:
|
|
5
|
+
// - Rules are evaluated in order deny, then ask, then allow; the first match
|
|
6
|
+
// in that order determines the outcome; specificity does not change order.
|
|
7
|
+
// - Bare tool-name deny removes the tool from context entirely.
|
|
8
|
+
// - Compound Bash commands: a rule must match each subcommand independently
|
|
9
|
+
// (allow) / any subcommand (deny/ask); wrappers and leading env assignments
|
|
10
|
+
// are stripped before matching.
|
|
11
|
+
// - Read deny rules also block Edit/Write tools on the same path and Bash
|
|
12
|
+
// file commands (cat, head, tail, sed…).
|
|
13
|
+
|
|
14
|
+
import { parseRule } from './parse-rule.js'
|
|
15
|
+
import {
|
|
16
|
+
ccBucket, ruleTargetsTool, isReadCoveredTool, isEditCoveredTool,
|
|
17
|
+
isBashReadCommand, isBashWriteCommand, extractBashPaths,
|
|
18
|
+
} from './map-tools.js'
|
|
19
|
+
import { winPathToPosix } from './patterns.js'
|
|
20
|
+
|
|
21
|
+
/** Shell wrappers CC strips before matching (docs: "process wrappers"). */
|
|
22
|
+
const WRAPPERS = new Set(['timeout', 'time', 'nice', 'nohup', 'stdbuf', 'command', 'builtin', 'noglob'])
|
|
23
|
+
|
|
24
|
+
/** Leading env assignments allow rules match past (a conservative subset). */
|
|
25
|
+
const SAFE_ENV = new Set(['NODE_ENV', 'CI', 'DEBUG', 'TERM', 'COLUMNS', 'LINES', 'EDITOR', 'PAGER', 'FORCE_COLOR', 'NO_COLOR', 'LANG', 'LC_ALL'])
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse raw rule strings from a merged permissions IR block into structured
|
|
29
|
+
* rules, classifying each as supported/invalid.
|
|
30
|
+
* @param {{deny: {raw:string}[], ask: {raw:string}[], allow: {raw:string}[]}} perm
|
|
31
|
+
* @returns {{ deny: object[], ask: object[], allow: object[], invalid: object[] }}
|
|
32
|
+
*/
|
|
33
|
+
export function parseRulesFor(perm) {
|
|
34
|
+
const out = { deny: [], ask: [], allow: [], invalid: [] }
|
|
35
|
+
for (const bucket of ['deny', 'ask', 'allow']) {
|
|
36
|
+
for (const r of perm[bucket] ?? []) {
|
|
37
|
+
const rule = parseRule(r.raw, { scope: r.scope, path: r.path })
|
|
38
|
+
if (rule === null || rule.invalid) { out.invalid.push(rule ?? { raw: r.raw, reason: 'unparseable' }); continue }
|
|
39
|
+
rule.bucket = bucket
|
|
40
|
+
out[bucket].push(rule)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Evaluate one tool call against parsed permission rules.
|
|
48
|
+
* @param {{ deny: object[], ask: object[], allow: object[] }} parsed - output of parseRulesFor.
|
|
49
|
+
* @param {{ tool: string, args: Record<string, unknown> }} call
|
|
50
|
+
* @param {object} env - { cwd, homeDir, settingsDirs: {user, project, local} }
|
|
51
|
+
* @returns {{ decision: 'deny'|'ask'|'allow'|'none', reason?: string, rule?: object }}
|
|
52
|
+
*/
|
|
53
|
+
export function evaluateCall(parsed, call, env) {
|
|
54
|
+
// 0. Bare-name / tool-glob deny removes the tool → any call is denied.
|
|
55
|
+
for (const rule of parsed.deny) {
|
|
56
|
+
if ((rule.kind === 'bare' || rule.kind === 'tool-glob') && matchBareTarget(rule, call.tool)) {
|
|
57
|
+
return { decision: 'deny', reason: `tool "${call.tool}" is denied by rule "${rule.raw}" (removed from context)`, rule }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// 1. deny
|
|
61
|
+
for (const rule of parsed.deny) {
|
|
62
|
+
if (matchRule(rule, call, env)) {
|
|
63
|
+
return { decision: 'deny', reason: `denied by "${rule.raw}"`, rule }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// 2. ask
|
|
67
|
+
for (const rule of parsed.ask) {
|
|
68
|
+
if (matchRule(rule, call, env)) {
|
|
69
|
+
return { decision: 'ask', reason: `a Claude Code permission rule ("${rule.raw}") requests confirmation`, rule }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 3. allow
|
|
73
|
+
for (const rule of parsed.allow) {
|
|
74
|
+
if (matchRule(rule, call, env)) {
|
|
75
|
+
return { decision: 'allow', rule }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { decision: 'none' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Bare rules and tool-glob rules match by tool name only. */
|
|
82
|
+
function matchBareTarget(rule, toolName) {
|
|
83
|
+
if (rule.kind === 'tool-glob') return rule.glob.test(toolName)
|
|
84
|
+
return ruleTargetsTool(rule.tool, toolName)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function matchRule(rule, call, env) {
|
|
88
|
+
switch (rule.kind) {
|
|
89
|
+
case 'bare':
|
|
90
|
+
case 'tool-glob':
|
|
91
|
+
return matchBareTarget(rule, call.tool)
|
|
92
|
+
case 'command':
|
|
93
|
+
return matchCommandRule(rule, call, env)
|
|
94
|
+
case 'path':
|
|
95
|
+
return matchPathRule(rule, call, env)
|
|
96
|
+
case 'domain':
|
|
97
|
+
return matchDomainRule(rule, call)
|
|
98
|
+
case 'param':
|
|
99
|
+
return matchParamRule(rule, call)
|
|
100
|
+
case 'agent-name':
|
|
101
|
+
// DSH's subagent tool carries no CC-style agent type name → never
|
|
102
|
+
// matches; the rule stays in the report for transparency.
|
|
103
|
+
return false
|
|
104
|
+
case 'skill-name':
|
|
105
|
+
return ccBucket(call.tool) === 'Skill' && call.args?.name === rule.name
|
|
106
|
+
default:
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── command (Bash/PowerShell) ───────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
function matchCommandRule(rule, call, env) {
|
|
114
|
+
const bucket = ccBucket(call.tool)
|
|
115
|
+
if (bucket !== 'Bash' && bucket !== 'PowerShell') return false
|
|
116
|
+
if (typeof call.args?.command !== 'string') return false
|
|
117
|
+
const subcommands = splitSubcommands(call.args.command)
|
|
118
|
+
const allowMode = rule.bucket === 'allow'
|
|
119
|
+
const stripped = subcommands.map((s) => {
|
|
120
|
+
const w = stripWrapper(s, { allowEnv: false })
|
|
121
|
+
// CC canonicalizes PowerShell aliases before matching: a rule written for
|
|
122
|
+
// the cmdlet name (Remove-Item) also matches its aliases (del, rm, ri…).
|
|
123
|
+
return bucket === 'PowerShell' ? canonicalizePowerShell(w) : w
|
|
124
|
+
})
|
|
125
|
+
if (allowMode) return stripped.length > 0 && stripped.every((s) => rule.command.test(s))
|
|
126
|
+
return stripped.some((s) => rule.command.test(s))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Common PowerShell aliases → cmdlet names (CC canonicalizes these before matching). */
|
|
130
|
+
const PS_ALIASES = new Map([
|
|
131
|
+
['ri', 'Remove-Item'], ['rm', 'Remove-Item'], ['del', 'Remove-Item'], ['erase', 'Remove-Item'], ['rd', 'Remove-Item'],
|
|
132
|
+
['gci', 'Get-ChildItem'], ['ls', 'Get-ChildItem'], ['dir', 'Get-ChildItem'],
|
|
133
|
+
['cat', 'Get-Content'], ['gc', 'Get-Content'], ['type', 'Get-Content'],
|
|
134
|
+
['cp', 'Copy-Item'], ['copy', 'Copy-Item'], ['cpi', 'Copy-Item'],
|
|
135
|
+
['mv', 'Move-Item'], ['move', 'Move-Item'], ['mi', 'Move-Item'],
|
|
136
|
+
['ni', 'New-Item'], ['ii', 'Invoke-Item'], ['gi', 'Get-Item'],
|
|
137
|
+
['sl', 'Set-Location'], ['cd', 'Set-Location'], ['chdir', 'Set-Location'],
|
|
138
|
+
['pwd', 'Get-Location'], ['gl', 'Get-Location'],
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
/** Rewrite a PowerShell command's leading alias to its canonical cmdlet name. */
|
|
142
|
+
function canonicalizePowerShell(cmd) {
|
|
143
|
+
const m = /^(\S+)(\s+.*)?$/.exec(cmd.trim())
|
|
144
|
+
if (!m) return cmd
|
|
145
|
+
const canonical = PS_ALIASES.get(m[1].toLowerCase())
|
|
146
|
+
if (canonical === undefined) return cmd
|
|
147
|
+
return m[2] !== undefined ? `${canonical}${m[2]}` : canonical
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Split a compound command on CC-recognized separators, quote-aware. */
|
|
151
|
+
export function splitSubcommands(command) {
|
|
152
|
+
const parts = []
|
|
153
|
+
let current = ''
|
|
154
|
+
let quote = null
|
|
155
|
+
let i = 0
|
|
156
|
+
const n = command.length
|
|
157
|
+
while (i < n) {
|
|
158
|
+
const ch = command[i]
|
|
159
|
+
if (quote !== null) {
|
|
160
|
+
current += ch
|
|
161
|
+
if (ch === quote) quote = null
|
|
162
|
+
i++
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; current += ch; i++; continue }
|
|
166
|
+
if (ch === '\\') { current += ch + (command[i + 1] ?? ''); i += 2; continue }
|
|
167
|
+
const two = command.slice(i, i + 2)
|
|
168
|
+
if (two === '&&' || two === '||' || two === '|&') {
|
|
169
|
+
parts.push(current.trim()); current = ''; i += 2; continue
|
|
170
|
+
}
|
|
171
|
+
if (ch === '&' || ch === '|' || ch === ';' || ch === '\n' || ch === '\r') {
|
|
172
|
+
parts.push(current.trim()); current = ''; i++; continue
|
|
173
|
+
}
|
|
174
|
+
current += ch
|
|
175
|
+
i++
|
|
176
|
+
}
|
|
177
|
+
parts.push(current.trim())
|
|
178
|
+
return parts.filter((p) => p.length > 0)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Strip leading env assignments, known wrappers and wrapper arguments. */
|
|
182
|
+
function stripWrapper(cmd, { allowEnv = false } = {}) {
|
|
183
|
+
let t = cmd.trim()
|
|
184
|
+
let changed = true
|
|
185
|
+
while (changed && t.length > 0) {
|
|
186
|
+
changed = false
|
|
187
|
+
const envm = /^([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S+)\s+/.exec(t)
|
|
188
|
+
if (envm) {
|
|
189
|
+
if (allowEnv && !SAFE_ENV.has(envm[1])) return t // allow rules don't pass unknown env
|
|
190
|
+
t = t.slice(envm[0].length).trim()
|
|
191
|
+
changed = true
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
const wm = /^(\S+)\s+/.exec(t)
|
|
195
|
+
if (wm && WRAPPERS.has(wm[1])) {
|
|
196
|
+
t = t.slice(wm[0].length).trim()
|
|
197
|
+
// Strip wrapper arguments: leading flags (with values) and a numeric
|
|
198
|
+
// duration (`timeout 30 npm test` → `npm test`, `nice -n 5 npm test` → …).
|
|
199
|
+
t = stripWrapperArgs(t)
|
|
200
|
+
changed = true
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (wm && wm[1] === 'xargs' && !t.slice(wm[0].length).trim().startsWith('-')) {
|
|
204
|
+
t = t.slice(wm[0].length).trim()
|
|
205
|
+
changed = true
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return t
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Strip leading flag tokens and a trailing numeric duration after a wrapper. */
|
|
213
|
+
function stripWrapperArgs(t) {
|
|
214
|
+
let out = t
|
|
215
|
+
let again = true
|
|
216
|
+
while (again && out.length > 0) {
|
|
217
|
+
again = false
|
|
218
|
+
const fm = /^-\w+(?:=\S+)?(\s+\S+)?/.exec(out)
|
|
219
|
+
if (fm) {
|
|
220
|
+
out = out.slice(fm[0].length).trim()
|
|
221
|
+
again = true
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
const nm = /^\d+(?:\.\d+)?[a-z]*(?:\s+|$)/.exec(out)
|
|
225
|
+
if (nm) {
|
|
226
|
+
out = out.slice(nm[0].length).trim()
|
|
227
|
+
again = true
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── path (Read/Edit/Cd) ─────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
function matchPathRule(rule, call, env) {
|
|
236
|
+
// Which tools this path rule applies to:
|
|
237
|
+
// Read rules → read-like tools AND Edit/Write tools (CC: a Read deny rule
|
|
238
|
+
// also blocks Edit/Write on the same path) AND Bash read file commands.
|
|
239
|
+
// Edit rules → edit/write-like tools AND Bash write file commands.
|
|
240
|
+
const bucket = ccBucket(call.tool)
|
|
241
|
+
const isFileTool = isReadCoveredTool(call.tool) || isEditCoveredTool(call.tool)
|
|
242
|
+
const isBash = bucket === 'Bash' || bucket === 'PowerShell'
|
|
243
|
+
if (isFileTool) {
|
|
244
|
+
const paths = extractFilePaths(call.args)
|
|
245
|
+
return paths.some((p) => matchPath(rule, p, env))
|
|
246
|
+
}
|
|
247
|
+
if (isBash && typeof call.args?.command === 'string') {
|
|
248
|
+
const cmd = call.args.command
|
|
249
|
+
const reads = rule.tool === 'Read' && isBashReadCommand(cmd)
|
|
250
|
+
const writes = rule.tool === 'Edit' && isBashWriteCommand(cmd)
|
|
251
|
+
if (!reads && !writes) return false
|
|
252
|
+
return extractBashPaths(cmd).some((p) => matchPath(rule, p, env))
|
|
253
|
+
}
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Extract candidate file paths from a file tool's arguments. */
|
|
258
|
+
function extractFilePaths(args) {
|
|
259
|
+
if (typeof args !== 'object' || args === null) return []
|
|
260
|
+
const out = []
|
|
261
|
+
for (const key of ['file_path', 'path', 'notebook_path']) {
|
|
262
|
+
const v = args[key]
|
|
263
|
+
if (typeof v === 'string' && v.length > 0) out.push(v)
|
|
264
|
+
}
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Match one file path against a compiled path rule with anchor resolution.
|
|
270
|
+
* `denyAskDepth` mirrors CC: a relative single-segment pattern (`src/**`)
|
|
271
|
+
* matches at any depth for deny/ask rules but only at the anchor for allow.
|
|
272
|
+
*/
|
|
273
|
+
function matchPath(rule, filePath, env) {
|
|
274
|
+
const compiled = rule.pathPattern
|
|
275
|
+
const target = winPathToPosix(filePath)
|
|
276
|
+
let rel
|
|
277
|
+
const base = anchorBase(rule, env)
|
|
278
|
+
if (base === undefined) return false
|
|
279
|
+
if (compiled.kind === 'absolute') rel = target
|
|
280
|
+
else if (target === base) rel = ''
|
|
281
|
+
else if (target.startsWith(base.endsWith('/') ? base : `${base}/`)) rel = target.slice(base.length).replace(/^\//, '')
|
|
282
|
+
else return false
|
|
283
|
+
const denyAsk = rule.bucket !== 'allow'
|
|
284
|
+
let source = compiled.re.source
|
|
285
|
+
if (compiled.prefixAny || (compiled.singleSegment && denyAsk)) {
|
|
286
|
+
// Insert an any-depth prefix after the anchor: `^secrets…` → `^(?:.*/)?secrets…`.
|
|
287
|
+
source = source.replace(/^\^/, '^') // keep the ^ if present
|
|
288
|
+
if (source.startsWith('^')) source = `^(?:.*/)?${source.slice(1)}`
|
|
289
|
+
else source = `(?:.*/)?${source}`
|
|
290
|
+
}
|
|
291
|
+
return new RegExp(source).test(rel)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The anchor directory (POSIX) for a rule's `kind`, by its source scope. */
|
|
295
|
+
function anchorBase(rule, env) {
|
|
296
|
+
const scope = rule.scope
|
|
297
|
+
const home = posix(env.homeDir)
|
|
298
|
+
switch (rule.pathPattern.kind) {
|
|
299
|
+
case 'absolute': return ''
|
|
300
|
+
case 'home': return home
|
|
301
|
+
case 'source':
|
|
302
|
+
if (scope === 'user') return `${home}/.claude`
|
|
303
|
+
if (scope === 'local') return posix(env.cwd)
|
|
304
|
+
return posix(env.projectRoot ?? env.cwd)
|
|
305
|
+
case 'cwd':
|
|
306
|
+
default:
|
|
307
|
+
return posix(env.cwd)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function posix(p) {
|
|
312
|
+
if (typeof p !== 'string') return '/'
|
|
313
|
+
return winPathToPosix(p).replace(/\/+$/, '') || '/'
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── domain (WebFetch) ───────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
function matchDomainRule(rule, call) {
|
|
319
|
+
if (ccBucket(call.tool) !== 'WebFetch') return false
|
|
320
|
+
if (rule.domainRe === undefined) return false
|
|
321
|
+
if (typeof call.args?.url !== 'string') return false
|
|
322
|
+
let host
|
|
323
|
+
try { host = new URL(call.args.url).hostname } catch { return false }
|
|
324
|
+
return rule.domainRe.test(host.toLowerCase().replace(/\.$/, ''))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── param (Tool(param:value)) ───────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
function matchParamRule(rule, call) {
|
|
330
|
+
const args = call.args
|
|
331
|
+
if (typeof args !== 'object' || args === null) return false
|
|
332
|
+
const v = args[rule.param]
|
|
333
|
+
if (v === undefined) return false // an omitted param never matches (CC)
|
|
334
|
+
const value = String(v)
|
|
335
|
+
if (rule.value.includes('*')) {
|
|
336
|
+
let out = ''
|
|
337
|
+
for (const ch of rule.value) out += ch === '*' ? '.*' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
338
|
+
return new RegExp(`^${out}$`).test(value)
|
|
339
|
+
}
|
|
340
|
+
return value === rule.value
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* CC hook `if`-field matching: does the rule select this call?
|
|
345
|
+
*
|
|
346
|
+
* CC evaluates `if` with permission-rule syntax but as a FILTER, not a
|
|
347
|
+
* decision: the hook runs when the rule matches, and stays silent otherwise.
|
|
348
|
+
* Two deliberate semantic choices, both documented in CC's hooks reference:
|
|
349
|
+
* - Bash/PowerShell command rules match when ANY subcommand matches (CC: "each
|
|
350
|
+
* subcommand is checked; git push matches"), which is the deny/ask (some)
|
|
351
|
+
* reading, not the allow (every) reading used by permission folding.
|
|
352
|
+
* - Path rules match anchored like allow rules (CC v2.1.214+: a single-segment
|
|
353
|
+
* pattern `Edit(src/**)` matches only the working-directory anchor, not any
|
|
354
|
+
* depth), so the rule is evaluated with bucket 'allow'.
|
|
355
|
+
*
|
|
356
|
+
* Fail-open is the CALLER's job: a rule that cannot be parsed must run the
|
|
357
|
+
* hook anyway (CC: "The filter also fails open … when the Bash command can't
|
|
358
|
+
* be parsed"), so this function assumes a parsed, valid rule.
|
|
359
|
+
*
|
|
360
|
+
* @param {object} rule - structured rule from {@link parseRule}.
|
|
361
|
+
* @param {{ tool: string, args: Record<string, unknown> }} call
|
|
362
|
+
* @param {object} env - { cwd, homeDir } matching context.
|
|
363
|
+
* @returns {boolean} whether the rule selects the call.
|
|
364
|
+
*/
|
|
365
|
+
export function matchesIfRule(rule, call, env) {
|
|
366
|
+
switch (rule.kind) {
|
|
367
|
+
case 'bare':
|
|
368
|
+
case 'tool-glob':
|
|
369
|
+
return matchBareTarget(rule, call.tool)
|
|
370
|
+
case 'command':
|
|
371
|
+
// any-subcommand semantics (deny bucket) — see header comment.
|
|
372
|
+
return matchCommandRule({ ...rule, bucket: 'deny' }, call, env)
|
|
373
|
+
case 'path':
|
|
374
|
+
// anchored (allow bucket) semantics — see header comment.
|
|
375
|
+
return matchPathRule({ ...rule, bucket: 'allow' }, call, env)
|
|
376
|
+
case 'domain':
|
|
377
|
+
return matchDomainRule(rule, call)
|
|
378
|
+
case 'param':
|
|
379
|
+
return matchParamRule(rule, call)
|
|
380
|
+
case 'skill-name':
|
|
381
|
+
return ccBucket(call.tool) === 'Skill' && call.args?.name === rule.name
|
|
382
|
+
case 'agent-name':
|
|
383
|
+
// `Agent(Name)` restricts a subagent by name; no tool call carries it.
|
|
384
|
+
return false
|
|
385
|
+
default:
|
|
386
|
+
return false
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─── tool removal (bare-name deny) ───────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Compute the tool names a settings file removes from context (bare-name deny
|
|
394
|
+
* and tool-glob deny rules). Adapters use this to hide tools per-agent.
|
|
395
|
+
* @returns {{ names: string[], globs: string[] }}
|
|
396
|
+
*/
|
|
397
|
+
export function removedToolNames(parsed) {
|
|
398
|
+
const names = new Set()
|
|
399
|
+
const globs = new Set()
|
|
400
|
+
for (const rule of parsed.deny) {
|
|
401
|
+
if (rule.kind === 'bare') names.add(rule.tool)
|
|
402
|
+
else if (rule.kind === 'tool-glob') globs.add(rule.tool)
|
|
403
|
+
}
|
|
404
|
+
return { names: [...names], globs: [...globs] }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── component classification ────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
/** Component status values used across the IR. */
|
|
410
|
+
export const STATUS = {
|
|
411
|
+
DIRECT: 'DIRECT',
|
|
412
|
+
ADAPTED: 'ADAPTED',
|
|
413
|
+
UNSUPPORTED: 'UNSUPPORTED',
|
|
414
|
+
BLOCKED: 'BLOCKED',
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Classify parsed components and build the compatibility report.
|
|
419
|
+
* @param {object} ir - the assembled IR (components + warnings).
|
|
420
|
+
* @returns {object} report { total, direct, adapted, unsupported, blocked, warnings }
|
|
421
|
+
*/
|
|
422
|
+
export function classifyComponents(ir) {
|
|
423
|
+
const counts = { total: 0, direct: 0, adapted: 0, unsupported: 0, blocked: 0 }
|
|
424
|
+
const unsupported = []
|
|
425
|
+
const consider = (status, kind, name, reason) => {
|
|
426
|
+
counts.total++
|
|
427
|
+
counts[status.toLowerCase()]++
|
|
428
|
+
if (status === STATUS.UNSUPPORTED || status === STATUS.BLOCKED) {
|
|
429
|
+
unsupported.push({ kind, name, status, reason })
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const s of ir.components.skills ?? []) consider(s.status ?? STATUS.DIRECT, 'skill', s.name, s.reason)
|
|
433
|
+
for (const c of ir.components.commands ?? []) consider(c.status ?? STATUS.DIRECT, 'command', c.name, c.reason)
|
|
434
|
+
for (const r of ir.components.rules ?? []) consider(r.status ?? STATUS.DIRECT, 'rule', r.name, r.reason)
|
|
435
|
+
for (const a of ir.components.agents ?? []) consider(a.status ?? STATUS.DIRECT, 'agent', a.name, a.notes?.join('; ') || undefined)
|
|
436
|
+
for (const s of ir.components.mcp?.servers ?? []) consider(s.status ?? STATUS.DIRECT, 'mcp-server', s.serverName, s.reason)
|
|
437
|
+
for (const s of ir.components.lsp?.servers ?? []) consider(s.status ?? STATUS.DIRECT, 'lsp-server', s.language, s.reason)
|
|
438
|
+
for (const p of ir.components.plugins ?? []) {
|
|
439
|
+
consider(STATUS.DIRECT, 'plugin', p.name, undefined)
|
|
440
|
+
for (const u of p.components?.unsupported ?? []) {
|
|
441
|
+
counts.total++
|
|
442
|
+
counts.unsupported++
|
|
443
|
+
unsupported.push({ ...u, plugin: p.name })
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
for (const mp of ir.components.marketplaces ?? []) {
|
|
447
|
+
if (mp.marketplace === undefined) continue
|
|
448
|
+
consider(STATUS.DIRECT, 'marketplace', mp.marketplace.name, undefined)
|
|
449
|
+
for (const entry of mp.plugins ?? []) {
|
|
450
|
+
consider(entry.status ?? STATUS.UNSUPPORTED, 'marketplace-plugin', entry.name, entry.reason)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const perm = ir.components.permissions
|
|
454
|
+
if (perm !== undefined) {
|
|
455
|
+
if (perm.status === STATUS.DIRECT || perm.status === STATUS.UNSUPPORTED) {
|
|
456
|
+
consider(perm.status, 'permissions', 'settings.json', perm.status === STATUS.UNSUPPORTED ? 'no permission rules found' : undefined)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const u of ir.components.unsupported ?? []) {
|
|
460
|
+
counts.total++
|
|
461
|
+
counts.unsupported++
|
|
462
|
+
unsupported.push(u)
|
|
463
|
+
}
|
|
464
|
+
return { ...counts, unsupported, warnings: ir.warnings ?? [] }
|
|
465
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// dsh-cc-loader — shared parse layer for the dsh-cc ecosystem.
|
|
2
|
+
//
|
|
3
|
+
// Parses Claude Code `.claude/` (project + global user dir) into a standalone
|
|
4
|
+
// in-memory IR. The IR is a plain JSON-serializable object; adapters consume
|
|
5
|
+
// it and never touch `.claude` details directly. Nothing is written to disk:
|
|
6
|
+
// the source of truth stays the `.claude` files themselves, so DSH stays in
|
|
7
|
+
// sync with Claude Code (which also scans `.claude` per session).
|
|
8
|
+
//
|
|
9
|
+
// Every component is classified DIRECT / ADAPTED / UNSUPPORTED / BLOCKED;
|
|
10
|
+
// UNSUPPORTED and BLOCKED components never reach the adapters.
|
|
11
|
+
|
|
12
|
+
export { loadClaude, loadPermissions } from './load.js'
|
|
13
|
+
export {
|
|
14
|
+
discoverSkills, discoverCommands, collectClaudeDir, discoverRules,
|
|
15
|
+
findProjectRoot, parseFrontmatter, isSkillName, pathExists, readTextSafe,
|
|
16
|
+
} from './skills.js'
|
|
17
|
+
export { discoverSettings, mergeSettings } from './settings.js'
|
|
18
|
+
export {
|
|
19
|
+
discoverAgents, mergeAgentCatalog, buildAgentEntry, classifyAgentFields,
|
|
20
|
+
expandCcToolToDsh,
|
|
21
|
+
} from './agents.js'
|
|
22
|
+
export {
|
|
23
|
+
parseMcpText, serverEntries, discoverMcpConfig, discoverProjectMcp,
|
|
24
|
+
VALID_SERVER_NAME, ENV_PLACEHOLDER,
|
|
25
|
+
} from './mcp.js'
|
|
26
|
+
export { discoverLspConfig, parseLspText } from './lsp.js'
|
|
27
|
+
export {
|
|
28
|
+
parsePluginManifest, parseMarketplace, discoverPluginRoot, discoverMarketplace,
|
|
29
|
+
normalizePluginSource, pluginNameOf, pluginComponentName,
|
|
30
|
+
PLUGIN_NAME_RE, RESERVED_MARKETPLACE_NAMES,
|
|
31
|
+
} from './plugin.js'
|
|
32
|
+
export { parseRule } from './parse-rule.js'
|
|
33
|
+
export { compileCommandPattern, compilePathPattern, compileDomainPattern, winPathToPosix, escapeRegExp } from './patterns.js'
|
|
34
|
+
export {
|
|
35
|
+
ccBucket, ruleTargetsTool, extractBashPaths, isBashReadCommand, isBashWriteCommand,
|
|
36
|
+
isReadCoveredTool, isEditCoveredTool,
|
|
37
|
+
} from './map-tools.js'
|
|
38
|
+
export {
|
|
39
|
+
evaluateCall, parseRulesFor, splitSubcommands, removedToolNames,
|
|
40
|
+
classifyComponents, STATUS, matchesIfRule,
|
|
41
|
+
} from './classify.js'
|
package/src/load.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// loadClaude: assemble the full IR from a cwd (project + global .claude).
|
|
2
|
+
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { findProjectRoot, collectClaudeDir, discoverRules } from './skills.js'
|
|
6
|
+
import { discoverSettings, mergeSettings } from './settings.js'
|
|
7
|
+
import { parseRulesFor, removedToolNames, classifyComponents } from './classify.js'
|
|
8
|
+
import { STATUS } from './classify.js'
|
|
9
|
+
import { mergeAgentCatalog } from './agents.js'
|
|
10
|
+
import { discoverProjectMcp } from './mcp.js'
|
|
11
|
+
import { discoverPluginRoot, discoverMarketplace } from './plugin.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load Claude Code `.claude/` assets (project + optional global ~/.claude)
|
|
15
|
+
* into a standalone IR. Pure parse: no writes, no side effects beyond reads.
|
|
16
|
+
* @param {object} [opts]
|
|
17
|
+
* @param {string} opts.cwd - session workspace (project root discovery starts here).
|
|
18
|
+
* @param {string} [opts.homeDir]
|
|
19
|
+
* @param {string[]} [opts.projectRootMarkers]
|
|
20
|
+
* @param {boolean} [opts.enableGlobal=true]
|
|
21
|
+
* @param {string} [opts.globalClaudeDir]
|
|
22
|
+
* @param {number} [opts.projectSkillRank=150]
|
|
23
|
+
* @param {number} [opts.globalSkillRank=160]
|
|
24
|
+
* @param {string[]} [opts.pluginRoots] - plugin dirs to inventory (M4).
|
|
25
|
+
* @param {string[]} [opts.marketplaceRoots] - marketplace dirs to inventory (M4).
|
|
26
|
+
* @param {number} [opts.pluginSkillRank=170] - plugin component rank (higher
|
|
27
|
+
* than global 160 so project/global entries win on name clashes).
|
|
28
|
+
* @returns {Promise<object>} IR { cwd, projectRoot, components, warnings, report }
|
|
29
|
+
*/
|
|
30
|
+
export async function loadClaude(opts = {}) {
|
|
31
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
32
|
+
const homeDir = opts.homeDir ?? homedir()
|
|
33
|
+
const markers = opts.projectRootMarkers ?? ['.git']
|
|
34
|
+
const projectSkillRank = opts.projectSkillRank ?? 150
|
|
35
|
+
const globalSkillRank = opts.globalSkillRank ?? 160
|
|
36
|
+
const warnings = []
|
|
37
|
+
|
|
38
|
+
const projectRoot = await findProjectRoot(cwd, markers)
|
|
39
|
+
const skills = []
|
|
40
|
+
const commands = []
|
|
41
|
+
const rules = []
|
|
42
|
+
const agentRoots = []
|
|
43
|
+
|
|
44
|
+
if (projectRoot !== undefined) {
|
|
45
|
+
const claudeDir = join(projectRoot, '.claude')
|
|
46
|
+
const dir = await collectClaudeDir(claudeDir, 'project-claude', projectSkillRank, warnings)
|
|
47
|
+
skills.push(...dir.skills)
|
|
48
|
+
commands.push(...dir.commands)
|
|
49
|
+
rules.push(...await discoverRules(join(claudeDir, 'rules'), 'project'))
|
|
50
|
+
agentRoots.push({ root: join(claudeDir, 'agents'), scope: 'project', rank: projectSkillRank })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opts.enableGlobal !== false) {
|
|
54
|
+
const globalDir = opts.globalClaudeDir ?? join(homeDir, '.claude')
|
|
55
|
+
if (globalDir !== '' && globalDir !== '.claude') {
|
|
56
|
+
const dir = await collectClaudeDir(globalDir, 'user-claude', globalSkillRank, warnings)
|
|
57
|
+
skills.push(...dir.skills)
|
|
58
|
+
commands.push(...dir.commands)
|
|
59
|
+
rules.push(...await discoverRules(join(globalDir, 'rules'), 'user'))
|
|
60
|
+
agentRoots.push({ root: join(globalDir, 'agents'), scope: 'global', rank: globalSkillRank })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Agents: merge project + global into one catalog (project wins on name clash).
|
|
65
|
+
const catalog = await mergeAgentCatalog(agentRoots, warnings)
|
|
66
|
+
warnings.push(...catalog.warnings)
|
|
67
|
+
|
|
68
|
+
// Permissions: discover the three settings files and merge.
|
|
69
|
+
const discovered = await discoverSettings(cwd, { homeDir, projectRootMarkers: markers })
|
|
70
|
+
const merged = mergeSettings(discovered)
|
|
71
|
+
const parsed = parseRulesFor(merged)
|
|
72
|
+
for (const invalid of parsed.invalid) {
|
|
73
|
+
warnings.push(`permission rule ignored: ${invalid.raw ?? ''} — ${invalid.reason}`)
|
|
74
|
+
}
|
|
75
|
+
const permissions = {
|
|
76
|
+
status: merged.status === STATUS.DIRECT ? STATUS.DIRECT : STATUS.UNSUPPORTED,
|
|
77
|
+
raw: { deny: merged.deny, ask: merged.ask, allow: merged.allow },
|
|
78
|
+
parsed,
|
|
79
|
+
removed: removedToolNames(parsed),
|
|
80
|
+
defaultMode: merged.defaultMode,
|
|
81
|
+
additionalDirectories: merged.additionalDirectories,
|
|
82
|
+
disableBypassPermissionsMode: merged.disableBypassPermissionsMode,
|
|
83
|
+
model: merged.model,
|
|
84
|
+
env: merged.env,
|
|
85
|
+
statusLine: merged.statusLine,
|
|
86
|
+
outputStyle: merged.outputStyle,
|
|
87
|
+
enableAllProjectMcpServers: merged.enableAllProjectMcpServers,
|
|
88
|
+
sources: merged.sources,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MCP servers: project-root .mcp.json (CC project-level config).
|
|
92
|
+
const mcp = { servers: [], sources: [] }
|
|
93
|
+
if (projectRoot !== undefined) {
|
|
94
|
+
const found = await discoverProjectMcp(projectRoot, { warn: (m) => warnings.push(m) })
|
|
95
|
+
mcp.servers.push(...found.servers)
|
|
96
|
+
mcp.sources.push(...found.sources)
|
|
97
|
+
warnings.push(...found.warnings)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// LSP servers: plugin-root .lsp.json — discovered by the plugin scanner
|
|
101
|
+
// (M4); loadClaude itself has no plugin roots, so this stays empty here.
|
|
102
|
+
// discoverLspConfig() is exported for adapters that do scan plugin dirs.
|
|
103
|
+
const lsp = { servers: [], sources: [] }
|
|
104
|
+
|
|
105
|
+
// Plugins + marketplaces (M4): each plugin root is inventoried into its own
|
|
106
|
+
// IR block. Plugin components stay namespaced by plugin (components.plugins)
|
|
107
|
+
// and are NOT merged into the top-level skills/commands/agents — adapters
|
|
108
|
+
// consume them through the plugin block and apply CC's <plugin>:<name>
|
|
109
|
+
// namespacing themselves.
|
|
110
|
+
const plugins = []
|
|
111
|
+
const marketplaces = []
|
|
112
|
+
const pluginSkillRank = opts.pluginSkillRank ?? 170
|
|
113
|
+
for (const root of opts.pluginRoots ?? []) {
|
|
114
|
+
const plugin = await discoverPluginRoot(root, { skillRank: pluginSkillRank, warn: (m) => warnings.push(m) })
|
|
115
|
+
warnings.push(...plugin.warnings)
|
|
116
|
+
plugins.push(plugin)
|
|
117
|
+
}
|
|
118
|
+
for (const root of opts.marketplaceRoots ?? []) {
|
|
119
|
+
const mp = await discoverMarketplace(root, { warn: (m) => warnings.push(m) })
|
|
120
|
+
warnings.push(...mp.warnings)
|
|
121
|
+
marketplaces.push(mp)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const components = {
|
|
125
|
+
skills: skills.map((s) => ({ ...s, status: s.status ?? STATUS.DIRECT })),
|
|
126
|
+
commands: commands.map((c) => ({ ...c, status: c.status ?? STATUS.DIRECT })),
|
|
127
|
+
rules: rules.map((r) => ({ ...r, status: r.status ?? STATUS.DIRECT })),
|
|
128
|
+
agents: catalog.agents,
|
|
129
|
+
mcp,
|
|
130
|
+
lsp,
|
|
131
|
+
permissions,
|
|
132
|
+
plugins,
|
|
133
|
+
marketplaces,
|
|
134
|
+
unsupported: [], // future: workflows/monitors/themes/bin classification lands here
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ir = { cwd, projectRoot, components, warnings }
|
|
138
|
+
ir.report = classifyComponents(ir)
|
|
139
|
+
return ir
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Load only the permission slice (settings.json → merged → parsed rules).
|
|
144
|
+
* Cheaper than loadClaude; used by the permission gate on every tool call.
|
|
145
|
+
* @returns {Promise<object>} { cwd, projectRoot, permissions, warnings }
|
|
146
|
+
*/
|
|
147
|
+
export async function loadPermissions(opts = {}) {
|
|
148
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
149
|
+
const homeDir = opts.homeDir ?? homedir()
|
|
150
|
+
const markers = opts.projectRootMarkers ?? ['.git']
|
|
151
|
+
const warnings = []
|
|
152
|
+
const discovered = await discoverSettings(cwd, { homeDir, projectRootMarkers: markers })
|
|
153
|
+
const merged = mergeSettings(discovered)
|
|
154
|
+
const parsed = parseRulesFor(merged)
|
|
155
|
+
for (const invalid of parsed.invalid) {
|
|
156
|
+
warnings.push(`permission rule ignored: ${invalid.raw ?? ''} — ${invalid.reason}`)
|
|
157
|
+
}
|
|
158
|
+
const permissions = {
|
|
159
|
+
status: merged.status === STATUS.DIRECT ? STATUS.DIRECT : STATUS.UNSUPPORTED,
|
|
160
|
+
raw: { deny: merged.deny, ask: merged.ask, allow: merged.allow },
|
|
161
|
+
parsed,
|
|
162
|
+
removed: removedToolNames(parsed),
|
|
163
|
+
defaultMode: merged.defaultMode,
|
|
164
|
+
additionalDirectories: merged.additionalDirectories,
|
|
165
|
+
disableBypassPermissionsMode: merged.disableBypassPermissionsMode,
|
|
166
|
+
model: merged.model,
|
|
167
|
+
env: merged.env,
|
|
168
|
+
statusLine: merged.statusLine,
|
|
169
|
+
outputStyle: merged.outputStyle,
|
|
170
|
+
enableAllProjectMcpServers: merged.enableAllProjectMcpServers,
|
|
171
|
+
sources: merged.sources,
|
|
172
|
+
}
|
|
173
|
+
return { cwd, projectRoot: discovered.projectRoot, permissions, warnings }
|
|
174
|
+
}
|