dsh-cc-permissions 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 ADDED
@@ -0,0 +1,43 @@
1
+ # dsh-cc-permissions
2
+
3
+ Enforce Claude Code permission rules in DSH.
4
+
5
+ Reads `.claude/settings.json` (user `~/.claude`, project, and `settings.local.json`)
6
+ through the shared [dsh-cc-loader](../cc-loader) parse layer and folds the rules
7
+ **deny → ask → allow** on every tool call, exactly like Claude Code:
8
+
9
+ - `Tool` bare deny removes the tool from the model's context (per-agent hide)
10
+ - `Tool(spec)` scoped rules match commands, paths, domains, params, MCP tools
11
+ - Bash compound commands are split; wrappers (`timeout`, `nice`, `nohup`…) and
12
+ leading env assignments are stripped before matching
13
+ - Read deny rules also gate Edit/Write tools and Bash file commands
14
+ (`cat`, `head`, `tail`, `sed`…)
15
+ - `ask` rules route through DSH's approval service
16
+
17
+ ## Read-only bridge
18
+
19
+ CC `settings.json` is a **read-only source**. DSH-side approvals ("don't ask
20
+ again") are stored in DSH's own config and are never written back to
21
+ `.claude/settings.local.json` — CC, Codex and DSH have different permission
22
+ semantics, so cross-tool permission sync is intentionally out of scope
23
+ (community `agents` / `cc-suite` do not sync permissions either).
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ dsh plugin --profile <name> add dsh-cc-permissions
29
+ ```
30
+
31
+ ## Config
32
+
33
+ | Key | Default | Meaning |
34
+ | --- | --- | --- |
35
+ | `enabled` | `true` | Master switch |
36
+ | `hideDeniedTools` | `true` | Bare-name deny → per-agent `ctx.tools.restrict` |
37
+ | `enableDefaultMode` | `true` | `defaultMode=dontAsk` → approval policy `never` |
38
+ | `homeDir` | `<home>` | Home dir for `~/.claude/settings.json` |
39
+ | `projectRootMarkers` | `['.git']` | Project root discovery |
40
+
41
+ ## License
42
+
43
+ MIT.
@@ -0,0 +1,6 @@
1
+ - insert:
2
+ - id: cc-permissions
3
+ name: 'dsh-cc-permissions'
4
+ config:
5
+ enabled: true
6
+ hideDeniedTools: true
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "dsh-cc-permissions",
3
+ "version": "0.1.0",
4
+ "description": "Enforce Claude Code permission rules (.claude/settings.json allow/deny/ask, deny>ask>allow) in DSH via a tools/pre-execute gate. Reads CC settings as a read-only source; DSH-side approvals never write back to .claude.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "dsh": {
11
+ "bundle": {
12
+ "patch": "./cordis.patch.yml"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "cordis.patch.yml"
19
+ ],
20
+ "keywords": [
21
+ "dsh",
22
+ "deepseek-harness",
23
+ "claude-code",
24
+ "permissions",
25
+ "security"
26
+ ],
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "dsh-cc-loader": "^0.1.0"
30
+ },
31
+ "peerDependencies": {
32
+ "@deepseek-ai/cordis": "*",
33
+ "@deepseek-ai/schemastery": "*"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@deepseek-ai/cordis": {
37
+ "optional": true
38
+ },
39
+ "@deepseek-ai/schemastery": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "repository": "git+https://github.com/Bcy2020/dsh-cc-ecosystem.git",
47
+ "homepage": "https://github.com/Bcy2020/dsh-cc-ecosystem"
48
+ }
package/src/index.js ADDED
@@ -0,0 +1,159 @@
1
+ // cc-permissions — enforce CC permission rules in DSH.
2
+ //
3
+ // Read-only bridge: `.claude/settings.json` (user/project/local) is parsed by
4
+ // dsh-cc-loader into rules, and a `tools/pre-execute` listener folds them
5
+ // deny → ask → allow exactly like Claude Code. DSH-side approvals never write
6
+ // back to `.claude` (no cross-tool permission sync — CC/Codex/DSH permission
7
+ // semantics differ).
8
+ //
9
+ // Bare-name deny rules additionally hide the tool per-agent via
10
+ // `agent.ctx.tools.restrict({ deny })`, so the model does not see removed
11
+ // tools (falls back to always-deny when a name is unknown to the registry).
12
+
13
+ import { stat } from 'node:fs/promises'
14
+ import { homedir } from 'node:os'
15
+ import z from '@deepseek-ai/schemastery'
16
+ import { loadPermissions, evaluateCall } from 'dsh-cc-loader'
17
+
18
+ export const name = 'cc-permissions'
19
+
20
+ export const Config = z.object({
21
+ enabled: z.boolean().default(true),
22
+ // Bare-name deny → per-agent tool hiding (restrict). Pre-execute always
23
+ // denies regardless; this only affects what the model sees.
24
+ hideDeniedTools: z.boolean().default(true),
25
+ // Map CC defaultMode: dontAsk → approval policy never (per session).
26
+ enableDefaultMode: z.boolean().default(true),
27
+ homeDir: z.string(),
28
+ projectRootMarkers: z.array(z.string()).default(['.git']),
29
+ })
30
+
31
+ export function apply(ctx, config = {}) {
32
+ if (config.enabled === false) return
33
+
34
+ const homeDir = config.homeDir ?? homedir()
35
+ // Per-cwd cache of loaded permissions; invalidated when any settings file's
36
+ // mtime changes, so edits take effect immediately (fresher than CC's
37
+ // load-at-startup semantics).
38
+ const cache = new Map()
39
+
40
+ async function permissionsFor(cwd) {
41
+ const hit = cache.get(cwd)
42
+ if (hit !== undefined) {
43
+ let changed = false
44
+ for (const src of hit.sources) {
45
+ try {
46
+ const s = await stat(src.path)
47
+ if (s.mtimeMs !== src.mtimeMs) { changed = true; break }
48
+ } catch { changed = true; break }
49
+ }
50
+ if (!changed) return hit
51
+ cache.delete(cwd)
52
+ }
53
+ const loaded = await loadPermissions({ cwd, homeDir, projectRootMarkers: config.projectRootMarkers })
54
+ const sources = (loaded.permissions.sources ?? []).map((src) => ({ path: src.path, mtimeMs: 0 }))
55
+ for (const src of sources) {
56
+ try { src.mtimeMs = (await stat(src.path)).mtimeMs } catch { /* keep 0 → always reload */ }
57
+ }
58
+ const entry = { ...loaded, sources, at: Date.now() }
59
+ cache.set(cwd, entry)
60
+ return entry
61
+ }
62
+
63
+ // ── gate: tools/pre-execute ────────────────────────────────────────────────
64
+ ctx.logger.info('cc-permissions: gate registered (reads .claude/settings.json allow/deny/ask)')
65
+ ctx.on('tools/pre-execute', async (exec, next) => {
66
+ const cwd = exec.agent?.session.header.cwd ?? process.cwd()
67
+ let loaded
68
+ try { loaded = await permissionsFor(cwd) } catch (error) {
69
+ ctx.logger.warn(`cc-permissions: load failed: ${String(error)}`)
70
+ return next()
71
+ }
72
+ const perm = loaded.permissions
73
+ if (perm === undefined) return next()
74
+ // Proceed when there are rules to fold OR when the MCP auto-approve toggle
75
+ // is on (it is meaningful even with an otherwise-empty settings file).
76
+ if (perm.status !== 'DIRECT' && perm.enableAllProjectMcpServers !== true) return next()
77
+ const result = evaluateCall(perm.parsed, { tool: exec.name, args: exec.arguments }, {
78
+ cwd: loaded.cwd,
79
+ homeDir,
80
+ projectRoot: loaded.projectRoot,
81
+ })
82
+ if (result.decision === 'deny') {
83
+ ctx.logger.info(`cc-permissions: DENY ${exec.name} (cwd=${cwd}) — ${result.reason}`)
84
+ return { kind: 'deny', reason: result.reason ?? 'Denied by a Claude Code permission rule.' }
85
+ }
86
+ if (result.decision === 'ask') {
87
+ ctx.logger.info(`cc-permissions: ASK ${exec.name} (cwd=${cwd}) — ${result.reason}`)
88
+ return { kind: 'ask', reason: result.reason ?? 'A Claude Code permission rule requests confirmation.' }
89
+ }
90
+ // CC settings `enableAllProjectMcpServers: true` auto-approves tools from
91
+ // PROJECT MCP servers (mcp__<server>__<tool>); plugin MCP tools keep their
92
+ // mcp__plugin_ namespace and are NOT covered. Slots in at the bottom of
93
+ // the deny → ask → allow fold: explicit deny/ask rules still win.
94
+ if (result.decision === 'none'
95
+ && perm.enableAllProjectMcpServers === true
96
+ && exec.name.startsWith('mcp__') && !exec.name.startsWith('mcp__plugin_')) {
97
+ ctx.logger.info(`cc-permissions: ALLOW ${exec.name} (cwd=${cwd}) — enableAllProjectMcpServers=true auto-approves project MCP tools`)
98
+ return { kind: 'allow', reason: 'enableAllProjectMcpServers=true auto-approves project MCP server tools.' }
99
+ }
100
+ return next()
101
+ })
102
+
103
+ // ── bare-name deny → hide the tool from the model ─────────────────────────
104
+ if (config.hideDeniedTools !== false) {
105
+ ctx.on('agent/created', ({ agent }) => {
106
+ const cwd = agent.session?.header?.cwd
107
+ if (cwd === undefined) return
108
+ void (async () => {
109
+ try {
110
+ const loaded = await permissionsFor(cwd)
111
+ const perm = loaded.permissions
112
+ if (perm === undefined || perm.status !== 'DIRECT') return
113
+ const { names } = perm.removed
114
+ if (names.length === 0) return
115
+ const tools = agent.ctx.tools
116
+ try {
117
+ tools.restrict({ deny: names })
118
+ } catch {
119
+ // Some names may not be in the registry yet / never registered —
120
+ // restrict per name so the known ones still hide.
121
+ for (const name of names) {
122
+ try { tools.restrict({ deny: [name] }) } catch { /* unknown tool: pre-execute still denies */ }
123
+ }
124
+ }
125
+ } catch (error) {
126
+ ctx.logger.warn(`cc-permissions: restrict failed: ${String(error)}`)
127
+ }
128
+ })()
129
+ })
130
+ }
131
+
132
+ // ── defaultMode → approval policy / guidance ──────────────────────────────
133
+ if (config.enableDefaultMode !== false) {
134
+ ctx.on('agent/session-start', ({ agent }) => {
135
+ const cwd = agent.session.header?.cwd
136
+ if (cwd === undefined) return
137
+ void (async () => {
138
+ try {
139
+ const loaded = await permissionsFor(cwd)
140
+ const mode = loaded.permissions?.defaultMode
141
+ if (mode === undefined) return
142
+ const approval = ctx.get('approval')
143
+ if (mode === 'dontAsk' && approval !== undefined) {
144
+ approval.setPolicy(agent, 'never')
145
+ ctx.logger.info(`cc-permissions: defaultMode=dontAsk → approval policy never for session`)
146
+ } else if (mode === 'bypassPermissions') {
147
+ ctx.logger.warn(`cc-permissions: defaultMode=bypassPermissions detected — DSH does NOT auto-map this to danger-full-access; enable it explicitly if intended`)
148
+ } else if (mode !== 'default' && mode !== 'manual') {
149
+ ctx.logger.info(`cc-permissions: defaultMode=${mode} noted (no direct DSH mapping; sandbox/approval presets govern)`)
150
+ }
151
+ } catch (error) {
152
+ ctx.logger.warn(`cc-permissions: defaultMode handling failed: ${String(error)}`)
153
+ }
154
+ })()
155
+ })
156
+ }
157
+
158
+ ctx.effect(() => () => cache.clear())
159
+ }