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 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.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.",
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": {
@@ -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 }) => {