dsh-cc-permissions 0.1.0 → 0.2.2
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 +27 -0
- package/package.json +8 -8
- package/src/approval.js +115 -0
- package/src/index.js +51 -0
package/README.md
CHANGED
|
@@ -13,6 +13,32 @@ through the shared [dsh-cc-loader](../cc-loader) parse layer and folds the rules
|
|
|
13
13
|
- Read deny rules also gate Edit/Write tools and Bash file commands
|
|
14
14
|
(`cat`, `head`, `tail`, `sed`…)
|
|
15
15
|
- `ask` rules route through DSH's approval service
|
|
16
|
+
- **`allow` rules answer the approval seam automatically** (see below)
|
|
17
|
+
|
|
18
|
+
## Allow = run without asking (approval seam)
|
|
19
|
+
|
|
20
|
+
In Claude Code an `allow` rule is the **complete gate**: the command runs without
|
|
21
|
+
any further question. DSH has a second layer below the permission fold — the
|
|
22
|
+
file sandbox. When `workspace-write` denies a file effect and the model retries
|
|
23
|
+
with `sandbox_permissions`, an `approval/request` is raised for the escalation;
|
|
24
|
+
without a bridge, that request would still hit the human answerer even though
|
|
25
|
+
the user already allowed the exact command.
|
|
26
|
+
|
|
27
|
+
`dsh-cc-permissions` bridges this: an `approval/request` (permission ask *or*
|
|
28
|
+
sandbox escalation) whose underlying tool call matches a CC `allow` rule is
|
|
29
|
+
answered `allowed-once` automatically — one-shot, rule-scoped, and read from
|
|
30
|
+
the **real tool arguments** in the session log (`tool/call` by `callId`, never
|
|
31
|
+
the model-written reason), mirroring the pattern of
|
|
32
|
+
[dsh-auto-approval-plugin](https://github.com/StyxNether/dsh-auto-approval-plugin).
|
|
33
|
+
|
|
34
|
+
- `Bash(pytest:*)` → the exact `pytest …` command may run outside the sandbox
|
|
35
|
+
when escalated (CC semantics restored)
|
|
36
|
+
- deny/ask rules and unmatched calls always defer to the human answerer; the
|
|
37
|
+
module only ever auto-grants, never auto-denies
|
|
38
|
+
- `enableAllProjectMcpServers: true` also covers project MCP tools
|
|
39
|
+
(`mcp__<server>__<tool>`, not `mcp__plugin_…`)
|
|
40
|
+
|
|
41
|
+
Disable with `autoApproveAllowed: false` in the plugin config.
|
|
16
42
|
|
|
17
43
|
## Read-only bridge
|
|
18
44
|
|
|
@@ -35,6 +61,7 @@ dsh plugin --profile <name> add dsh-cc-permissions
|
|
|
35
61
|
| `enabled` | `true` | Master switch |
|
|
36
62
|
| `hideDeniedTools` | `true` | Bare-name deny → per-agent `ctx.tools.restrict` |
|
|
37
63
|
| `enableDefaultMode` | `true` | `defaultMode=dontAsk` → approval policy `never` |
|
|
64
|
+
| `autoApproveAllowed` | `true` | `allow` rules auto-answer `approval/request` (incl. sandbox escalation) |
|
|
38
65
|
| `homeDir` | `<home>` | Home dir for `~/.claude/settings.json` |
|
|
39
66
|
| `projectRootMarkers` | `['.git']` | Project root discovery |
|
|
40
67
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cc-permissions",
|
|
3
|
-
"version": "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.",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Enforce Claude Code permission rules (.claude/settings.json allow/deny/ask, deny>ask>allow) in DSH via a tools/pre-execute gate; CC allow rules auto-answer the approval/request seam (incl. sandbox escalation). Reads CC settings as a read-only source; DSH-side approvals never write back to .claude.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -26,18 +26,18 @@
|
|
|
26
26
|
],
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"dsh-cc-loader": "^0.1.0"
|
|
29
|
+
"dsh-cc-loader": "^0.1.0",
|
|
30
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@deepseek-ai/cordis": "4.0.1"
|
|
30
34
|
},
|
|
31
35
|
"peerDependencies": {
|
|
32
|
-
"@deepseek-ai/cordis": "*"
|
|
33
|
-
"@deepseek-ai/schemastery": "*"
|
|
36
|
+
"@deepseek-ai/cordis": "*"
|
|
34
37
|
},
|
|
35
38
|
"peerDependenciesMeta": {
|
|
36
39
|
"@deepseek-ai/cordis": {
|
|
37
40
|
"optional": true
|
|
38
|
-
},
|
|
39
|
-
"@deepseek-ai/schemastery": {
|
|
40
|
-
"optional": true
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
package/src/approval.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// cc-permissions — CC allow rules answer the DSH approval seam.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code semantics: an `allow` rule (e.g. `Bash(pytest:*)`) is the
|
|
4
|
+
// COMPLETE gate — the command runs without any further question. DSH has a
|
|
5
|
+
// second layer below the permission fold: the file sandbox. A call that
|
|
6
|
+
// passes pre-execute still runs under `workspace-write`, and when the sandbox
|
|
7
|
+
// denies a file effect the model retries with `sandbox_permissions`, which
|
|
8
|
+
// raises an `approval/request` (the escalation seam). Without this module,
|
|
9
|
+
// that request hits the human answerer even though the user already allowed
|
|
10
|
+
// the exact command in `.claude/settings.json`.
|
|
11
|
+
//
|
|
12
|
+
// This module bridges the gap: an `approval/request` whose underlying tool
|
|
13
|
+
// call matches a CC `allow` rule is answered `allowed-once` automatically
|
|
14
|
+
// (the user pre-consented; CC allow = run without asking). Deny/ask rules and
|
|
15
|
+
// unmatched calls pass through to the deployment's human answerer untouched —
|
|
16
|
+
// the module only ever auto-grants, never auto-denies, and it never answers
|
|
17
|
+
// a call the CC fold did not explicitly allow.
|
|
18
|
+
//
|
|
19
|
+
// The request itself carries no arguments (`{agent, toolName, callId,
|
|
20
|
+
// reason}`), so the real tool arguments are recovered from the session log:
|
|
21
|
+
// the `tool/call` event keyed by `callId` (`data.arguments` is JSON text).
|
|
22
|
+
|
|
23
|
+
import { evaluateCall } from 'dsh-cc-loader'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Locate the recorded `tool/call` event for one approval request. The
|
|
27
|
+
* session log is scanned newest-first; the latest event matching the call id
|
|
28
|
+
* (and, when given, the tool name) wins — same shape as
|
|
29
|
+
* dsh-auto-approval-plugin's findToolCall.
|
|
30
|
+
* @param {unknown} events - the agent session's event log.
|
|
31
|
+
* @param {unknown} callId - the approval request's call id.
|
|
32
|
+
* @param {string} [toolName] - the approval request's tool name.
|
|
33
|
+
* @returns {{callId?: unknown, name?: string, arguments?: unknown}|undefined}
|
|
34
|
+
* the matching tool call data, or undefined when absent.
|
|
35
|
+
*/
|
|
36
|
+
export function findToolCall(events, callId, toolName) {
|
|
37
|
+
if (typeof callId !== 'string' || callId === '') return undefined
|
|
38
|
+
if (!Array.isArray(events)) return undefined
|
|
39
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
40
|
+
const event = events[index]
|
|
41
|
+
if (event === null || typeof event !== 'object' || event.type !== 'tool/call') continue
|
|
42
|
+
const data = event.data
|
|
43
|
+
if (data === undefined || data === null || typeof data !== 'object') continue
|
|
44
|
+
if (data.callId !== callId) continue
|
|
45
|
+
if (toolName !== undefined && data.name !== undefined && data.name !== toolName) continue
|
|
46
|
+
return data
|
|
47
|
+
}
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse recorded tool arguments: the session log stores them as JSON text
|
|
53
|
+
* (lossless materialization), but object form is tolerated for fixtures.
|
|
54
|
+
* @param {unknown} raw - the recorded `tool/call` arguments.
|
|
55
|
+
* @returns {Record<string, unknown>|null} the parsed arguments, `{}` when
|
|
56
|
+
* absent, or `null` when the text is unparseable (caller defers).
|
|
57
|
+
*/
|
|
58
|
+
export function parseArguments(raw) {
|
|
59
|
+
if (raw === undefined || raw === null) return {}
|
|
60
|
+
if (typeof raw === 'string') {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(raw)
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return typeof raw === 'object' ? raw : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Decide whether one approval request may be auto-granted from CC rules.
|
|
72
|
+
*
|
|
73
|
+
* Combines the session-log call lookup with the cc-loader rule fold
|
|
74
|
+
* (`evaluateCall`): only an explicit `allow` match — or an
|
|
75
|
+
* `enableAllProjectMcpServers`-covered project MCP tool on a no-rule call —
|
|
76
|
+
* yields `'allow'`. A deny or ask decision on the same call defers (the fold
|
|
77
|
+
* order deny > ask > allow is preserved; ask still asks the human).
|
|
78
|
+
*
|
|
79
|
+
* @param {object} input
|
|
80
|
+
* @param {unknown} input.events - the agent session's event log.
|
|
81
|
+
* @param {unknown} input.callId - the approval request's call id.
|
|
82
|
+
* @param {string} input.toolName - the approval request's tool name.
|
|
83
|
+
* @param {object} input.parsed - `parseRulesFor` output for the session.
|
|
84
|
+
* @param {object} input.env - `{ cwd, homeDir, projectRoot }` evaluation env.
|
|
85
|
+
* @param {boolean} [input.enableAllProjectMcpServers] - settings flag.
|
|
86
|
+
* @param {(name: string) => boolean} [input.isProjectMcpTool] - project MCP
|
|
87
|
+
* tool predicate (plugin MCP tools are never covered).
|
|
88
|
+
* @returns {'allow'|'defer'} whether to answer `allowed-once`.
|
|
89
|
+
*/
|
|
90
|
+
export function decideApproval({
|
|
91
|
+
events,
|
|
92
|
+
callId,
|
|
93
|
+
toolName,
|
|
94
|
+
parsed,
|
|
95
|
+
env,
|
|
96
|
+
enableAllProjectMcpServers = false,
|
|
97
|
+
isProjectMcpTool = () => false,
|
|
98
|
+
}) {
|
|
99
|
+
const call = findToolCall(events, callId, toolName)
|
|
100
|
+
if (call === undefined) return 'defer'
|
|
101
|
+
// No recorded arguments → no verification possible → defer ("no data, no
|
|
102
|
+
// auto-approval"); unparseable text defers too.
|
|
103
|
+
if (call.arguments === undefined || call.arguments === null) return 'defer'
|
|
104
|
+
const args = parseArguments(call.arguments)
|
|
105
|
+
if (args === null) return 'defer'
|
|
106
|
+
const name = typeof call.name === 'string' ? call.name : toolName
|
|
107
|
+
const result = evaluateCall(parsed, { tool: name, args }, env)
|
|
108
|
+
if (result.decision === 'allow') return 'allow'
|
|
109
|
+
if (result.decision === 'none'
|
|
110
|
+
&& enableAllProjectMcpServers === true
|
|
111
|
+
&& isProjectMcpTool(name)) {
|
|
112
|
+
return 'allow'
|
|
113
|
+
}
|
|
114
|
+
return 'defer'
|
|
115
|
+
}
|
package/src/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { stat } from 'node:fs/promises'
|
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
15
|
import z from '@deepseek-ai/schemastery'
|
|
16
16
|
import { loadPermissions, evaluateCall } from 'dsh-cc-loader'
|
|
17
|
+
import { decideApproval } from './approval.js'
|
|
17
18
|
|
|
18
19
|
export const name = 'cc-permissions'
|
|
19
20
|
|
|
@@ -24,6 +25,11 @@ export const Config = z.object({
|
|
|
24
25
|
hideDeniedTools: z.boolean().default(true),
|
|
25
26
|
// Map CC defaultMode: dontAsk → approval policy never (per session).
|
|
26
27
|
enableDefaultMode: z.boolean().default(true),
|
|
28
|
+
// Auto-answer approval/request (incl. sandbox escalation) when the call
|
|
29
|
+
// matches a CC allow rule — CC semantics: allow = run without asking.
|
|
30
|
+
// Granting is one-shot and rule-scoped; deny/ask rules and unmatched calls
|
|
31
|
+
// still reach the human answerer.
|
|
32
|
+
autoApproveAllowed: z.boolean().default(true),
|
|
27
33
|
homeDir: z.string(),
|
|
28
34
|
projectRootMarkers: z.array(z.string()).default(['.git']),
|
|
29
35
|
})
|
|
@@ -100,6 +106,51 @@ export function apply(ctx, config = {}) {
|
|
|
100
106
|
return next()
|
|
101
107
|
})
|
|
102
108
|
|
|
109
|
+
// ── allow → approval seam: approval/request auto-answerer ─────────────────
|
|
110
|
+
// CC allow rules are the COMPLETE gate: an allowed command must not hit the
|
|
111
|
+
// human answerer, including when the file sandbox denies a file effect and
|
|
112
|
+
// the model retries with `sandbox_permissions` (which raises an
|
|
113
|
+
// approval/request for the escalation). This listener answers such requests
|
|
114
|
+
// `allowed-once` when the underlying tool call matches a CC allow rule; the
|
|
115
|
+
// real arguments come from the session log (`tool/call` by callId), never
|
|
116
|
+
// from the model-written reason. Deny/ask rules and unmatched calls defer.
|
|
117
|
+
if (config.autoApproveAllowed !== false) {
|
|
118
|
+
ctx.on('approval/request', async (req, next) => {
|
|
119
|
+
try {
|
|
120
|
+
const events = req.agent?.session?.events
|
|
121
|
+
if (!Array.isArray(events)) return next()
|
|
122
|
+
const cwd = req.agent?.session?.header?.cwd ?? process.cwd()
|
|
123
|
+
let loaded
|
|
124
|
+
try {
|
|
125
|
+
loaded = await permissionsFor(cwd)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
ctx.logger.warn(`cc-permissions: approval auto-answer load failed: ${String(error)}`)
|
|
128
|
+
return next()
|
|
129
|
+
}
|
|
130
|
+
const perm = loaded.permissions
|
|
131
|
+
if (perm === undefined) return next()
|
|
132
|
+
if (perm.status !== 'DIRECT' && perm.enableAllProjectMcpServers !== true) return next()
|
|
133
|
+
const decision = decideApproval({
|
|
134
|
+
events,
|
|
135
|
+
callId: req.callId,
|
|
136
|
+
toolName: req.toolName,
|
|
137
|
+
parsed: perm.parsed,
|
|
138
|
+
env: { cwd: loaded.cwd, homeDir, projectRoot: loaded.projectRoot },
|
|
139
|
+
enableAllProjectMcpServers: perm.enableAllProjectMcpServers === true,
|
|
140
|
+
isProjectMcpTool: (name) => name.startsWith('mcp__') && !name.startsWith('mcp__plugin_'),
|
|
141
|
+
})
|
|
142
|
+
if (decision === 'allow') {
|
|
143
|
+
ctx.logger.info(`cc-permissions: AUTO-APPROVE ${req.toolName}${req.callId !== undefined ? ` call ${req.callId}` : ''} — matched a CC allow rule`)
|
|
144
|
+
return 'allowed-once'
|
|
145
|
+
}
|
|
146
|
+
return next()
|
|
147
|
+
} catch (error) {
|
|
148
|
+
ctx.logger.warn(`cc-permissions: approval auto-answer failed: ${String(error)}`)
|
|
149
|
+
return next()
|
|
150
|
+
}
|
|
151
|
+
}, { prepend: true })
|
|
152
|
+
}
|
|
153
|
+
|
|
103
154
|
// ── bare-name deny → hide the tool from the model ─────────────────────────
|
|
104
155
|
if (config.hideDeniedTools !== false) {
|
|
105
156
|
ctx.on('agent/created', ({ agent }) => {
|