@doingdev/opencode-claude-manager-plugin 0.1.52 → 0.1.53
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 +1 -1
- package/dist/claude/tool-approval-manager.js +19 -9
- package/dist/plugin/claude-manager.plugin.js +6 -1
- package/dist/src/claude/tool-approval-manager.js +19 -9
- package/dist/src/plugin/claude-manager.plugin.js +6 -1
- package/dist/src/types/contracts.d.ts +5 -1
- package/dist/test/tool-approval-manager.test.js +7 -11
- package/dist/types/contracts.d.ts +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ If you are testing locally, point OpenCode at the local package or plugin file u
|
|
|
81
81
|
|
|
82
82
|
- `approval_policy` — view the current tool approval policy.
|
|
83
83
|
- `approval_decisions` — view recent tool approval decisions.
|
|
84
|
-
- `approval_update` — add/remove rules,
|
|
84
|
+
- `approval_update` — add/remove rules, enable/disable approval, or clear decision history. Policy uses a **deny-list**: tools not matching any rule are **allowed**; use explicit **deny** rules to block. `defaultAction` is always `allow` (cannot be set to deny).
|
|
85
85
|
|
|
86
86
|
## Agent hierarchy
|
|
87
87
|
|
|
@@ -3,6 +3,13 @@ import path from 'node:path';
|
|
|
3
3
|
import { isFileNotFoundError, writeJsonAtomically } from '../util/fs-helpers.js';
|
|
4
4
|
const DEFAULT_MAX_DECISIONS = 500;
|
|
5
5
|
const INPUT_PREVIEW_MAX = 300;
|
|
6
|
+
function normalizePolicy(policy) {
|
|
7
|
+
return {
|
|
8
|
+
...policy,
|
|
9
|
+
rules: [...policy.rules],
|
|
10
|
+
defaultAction: 'allow',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
6
13
|
function getDefaultRules() {
|
|
7
14
|
return [
|
|
8
15
|
// Safe read-only tools
|
|
@@ -125,12 +132,12 @@ export class ToolApprovalManager {
|
|
|
125
132
|
maxDecisions;
|
|
126
133
|
persistPath;
|
|
127
134
|
constructor(policy, maxDecisions, persistPath) {
|
|
128
|
-
this.policy = {
|
|
135
|
+
this.policy = normalizePolicy({
|
|
129
136
|
rules: policy?.rules ?? getDefaultRules(),
|
|
130
|
-
defaultAction:
|
|
137
|
+
defaultAction: 'allow',
|
|
131
138
|
defaultDenyMessage: policy?.defaultDenyMessage ?? 'Tool call denied by approval policy.',
|
|
132
139
|
enabled: policy?.enabled ?? true,
|
|
133
|
-
};
|
|
140
|
+
});
|
|
134
141
|
this.maxDecisions = maxDecisions ?? DEFAULT_MAX_DECISIONS;
|
|
135
142
|
this.persistPath = persistPath ?? null;
|
|
136
143
|
}
|
|
@@ -141,12 +148,12 @@ export class ToolApprovalManager {
|
|
|
141
148
|
try {
|
|
142
149
|
const content = await fs.readFile(this.persistPath, 'utf8');
|
|
143
150
|
const loaded = JSON.parse(content);
|
|
144
|
-
this.policy = {
|
|
151
|
+
this.policy = normalizePolicy({
|
|
145
152
|
rules: loaded.rules ?? getDefaultRules(),
|
|
146
|
-
defaultAction:
|
|
153
|
+
defaultAction: 'allow',
|
|
147
154
|
defaultDenyMessage: loaded.defaultDenyMessage ?? 'Tool call denied by approval policy.',
|
|
148
155
|
enabled: loaded.enabled ?? true,
|
|
149
|
-
};
|
|
156
|
+
});
|
|
150
157
|
}
|
|
151
158
|
catch (error) {
|
|
152
159
|
if (!isFileNotFoundError(error)) {
|
|
@@ -167,7 +174,7 @@ export class ToolApprovalManager {
|
|
|
167
174
|
}
|
|
168
175
|
const inputJson = safeJsonStringify(input);
|
|
169
176
|
const matchedRule = this.findMatchingRule(toolName, inputJson);
|
|
170
|
-
const action = matchedRule?.action ??
|
|
177
|
+
const action = matchedRule?.action ?? 'allow';
|
|
171
178
|
const denyMessage = action === 'deny'
|
|
172
179
|
? (matchedRule?.denyMessage ?? this.policy.defaultDenyMessage ?? 'Denied by policy.')
|
|
173
180
|
: undefined;
|
|
@@ -201,7 +208,7 @@ export class ToolApprovalManager {
|
|
|
201
208
|
return { ...this.policy, rules: [...this.policy.rules] };
|
|
202
209
|
}
|
|
203
210
|
async setPolicy(policy) {
|
|
204
|
-
this.policy =
|
|
211
|
+
this.policy = normalizePolicy(policy);
|
|
205
212
|
await this.persistPolicy();
|
|
206
213
|
}
|
|
207
214
|
async addRule(rule, position) {
|
|
@@ -223,7 +230,10 @@ export class ToolApprovalManager {
|
|
|
223
230
|
return true;
|
|
224
231
|
}
|
|
225
232
|
async setDefaultAction(action) {
|
|
226
|
-
|
|
233
|
+
if (action === 'deny') {
|
|
234
|
+
throw new Error('defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.');
|
|
235
|
+
}
|
|
236
|
+
this.policy = normalizePolicy(this.policy);
|
|
227
237
|
await this.persistPolicy();
|
|
228
238
|
}
|
|
229
239
|
async setEnabled(enabled) {
|
|
@@ -289,7 +289,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
289
289
|
},
|
|
290
290
|
}),
|
|
291
291
|
approval_update: tool({
|
|
292
|
-
description: 'Update the tool approval policy. Add or remove rules,
|
|
292
|
+
description: 'Update the tool approval policy. Add or remove rules, enable or disable approvals, or clear decision history. Unmatched tools are always allowed; block only with explicit deny rules (defaultAction cannot be set to deny).',
|
|
293
293
|
args: {
|
|
294
294
|
action: tool.schema.enum([
|
|
295
295
|
'addRule',
|
|
@@ -336,6 +336,11 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
336
336
|
if (!args.defaultAction) {
|
|
337
337
|
return JSON.stringify({ error: 'setDefault requires defaultAction' });
|
|
338
338
|
}
|
|
339
|
+
if (args.defaultAction === 'deny') {
|
|
340
|
+
return JSON.stringify({
|
|
341
|
+
error: 'defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
339
344
|
await services.approvalManager.setDefaultAction(args.defaultAction);
|
|
340
345
|
}
|
|
341
346
|
else if (args.action === 'setEnabled') {
|
|
@@ -3,6 +3,13 @@ import path from 'node:path';
|
|
|
3
3
|
import { isFileNotFoundError, writeJsonAtomically } from '../util/fs-helpers.js';
|
|
4
4
|
const DEFAULT_MAX_DECISIONS = 500;
|
|
5
5
|
const INPUT_PREVIEW_MAX = 300;
|
|
6
|
+
function normalizePolicy(policy) {
|
|
7
|
+
return {
|
|
8
|
+
...policy,
|
|
9
|
+
rules: [...policy.rules],
|
|
10
|
+
defaultAction: 'allow',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
6
13
|
function getDefaultRules() {
|
|
7
14
|
return [
|
|
8
15
|
// Safe read-only tools
|
|
@@ -125,12 +132,12 @@ export class ToolApprovalManager {
|
|
|
125
132
|
maxDecisions;
|
|
126
133
|
persistPath;
|
|
127
134
|
constructor(policy, maxDecisions, persistPath) {
|
|
128
|
-
this.policy = {
|
|
135
|
+
this.policy = normalizePolicy({
|
|
129
136
|
rules: policy?.rules ?? getDefaultRules(),
|
|
130
|
-
defaultAction:
|
|
137
|
+
defaultAction: 'allow',
|
|
131
138
|
defaultDenyMessage: policy?.defaultDenyMessage ?? 'Tool call denied by approval policy.',
|
|
132
139
|
enabled: policy?.enabled ?? true,
|
|
133
|
-
};
|
|
140
|
+
});
|
|
134
141
|
this.maxDecisions = maxDecisions ?? DEFAULT_MAX_DECISIONS;
|
|
135
142
|
this.persistPath = persistPath ?? null;
|
|
136
143
|
}
|
|
@@ -141,12 +148,12 @@ export class ToolApprovalManager {
|
|
|
141
148
|
try {
|
|
142
149
|
const content = await fs.readFile(this.persistPath, 'utf8');
|
|
143
150
|
const loaded = JSON.parse(content);
|
|
144
|
-
this.policy = {
|
|
151
|
+
this.policy = normalizePolicy({
|
|
145
152
|
rules: loaded.rules ?? getDefaultRules(),
|
|
146
|
-
defaultAction:
|
|
153
|
+
defaultAction: 'allow',
|
|
147
154
|
defaultDenyMessage: loaded.defaultDenyMessage ?? 'Tool call denied by approval policy.',
|
|
148
155
|
enabled: loaded.enabled ?? true,
|
|
149
|
-
};
|
|
156
|
+
});
|
|
150
157
|
}
|
|
151
158
|
catch (error) {
|
|
152
159
|
if (!isFileNotFoundError(error)) {
|
|
@@ -167,7 +174,7 @@ export class ToolApprovalManager {
|
|
|
167
174
|
}
|
|
168
175
|
const inputJson = safeJsonStringify(input);
|
|
169
176
|
const matchedRule = this.findMatchingRule(toolName, inputJson);
|
|
170
|
-
const action = matchedRule?.action ??
|
|
177
|
+
const action = matchedRule?.action ?? 'allow';
|
|
171
178
|
const denyMessage = action === 'deny'
|
|
172
179
|
? (matchedRule?.denyMessage ?? this.policy.defaultDenyMessage ?? 'Denied by policy.')
|
|
173
180
|
: undefined;
|
|
@@ -201,7 +208,7 @@ export class ToolApprovalManager {
|
|
|
201
208
|
return { ...this.policy, rules: [...this.policy.rules] };
|
|
202
209
|
}
|
|
203
210
|
async setPolicy(policy) {
|
|
204
|
-
this.policy =
|
|
211
|
+
this.policy = normalizePolicy(policy);
|
|
205
212
|
await this.persistPolicy();
|
|
206
213
|
}
|
|
207
214
|
async addRule(rule, position) {
|
|
@@ -223,7 +230,10 @@ export class ToolApprovalManager {
|
|
|
223
230
|
return true;
|
|
224
231
|
}
|
|
225
232
|
async setDefaultAction(action) {
|
|
226
|
-
|
|
233
|
+
if (action === 'deny') {
|
|
234
|
+
throw new Error('defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.');
|
|
235
|
+
}
|
|
236
|
+
this.policy = normalizePolicy(this.policy);
|
|
227
237
|
await this.persistPolicy();
|
|
228
238
|
}
|
|
229
239
|
async setEnabled(enabled) {
|
|
@@ -289,7 +289,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
289
289
|
},
|
|
290
290
|
}),
|
|
291
291
|
approval_update: tool({
|
|
292
|
-
description: 'Update the tool approval policy. Add or remove rules,
|
|
292
|
+
description: 'Update the tool approval policy. Add or remove rules, enable or disable approvals, or clear decision history. Unmatched tools are always allowed; block only with explicit deny rules (defaultAction cannot be set to deny).',
|
|
293
293
|
args: {
|
|
294
294
|
action: tool.schema.enum([
|
|
295
295
|
'addRule',
|
|
@@ -336,6 +336,11 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
336
336
|
if (!args.defaultAction) {
|
|
337
337
|
return JSON.stringify({ error: 'setDefault requires defaultAction' });
|
|
338
338
|
}
|
|
339
|
+
if (args.defaultAction === 'deny') {
|
|
340
|
+
return JSON.stringify({
|
|
341
|
+
error: 'defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
339
344
|
await services.approvalManager.setDefaultAction(args.defaultAction);
|
|
340
345
|
}
|
|
341
346
|
else if (args.action === 'setEnabled') {
|
|
@@ -186,7 +186,11 @@ export interface ToolApprovalRule {
|
|
|
186
186
|
}
|
|
187
187
|
export interface ToolApprovalPolicy {
|
|
188
188
|
rules: ToolApprovalRule[];
|
|
189
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Always `allow`: tools that do not match any rule are allowed.
|
|
191
|
+
* Blocking is done only with explicit `deny` rules (deny-list contract).
|
|
192
|
+
*/
|
|
193
|
+
defaultAction: 'allow';
|
|
190
194
|
defaultDenyMessage?: string;
|
|
191
195
|
enabled: boolean;
|
|
192
196
|
}
|
|
@@ -73,13 +73,12 @@ describe('ToolApprovalManager', () => {
|
|
|
73
73
|
const result = manager.evaluate('Bash', { command: 'rm -rf /' });
|
|
74
74
|
expect(result).toEqual({ behavior: 'allow' });
|
|
75
75
|
});
|
|
76
|
-
it('
|
|
76
|
+
it('allows tools when no rule matches (deny-list contract)', () => {
|
|
77
77
|
const manager = new ToolApprovalManager({
|
|
78
78
|
rules: [],
|
|
79
|
-
defaultAction: 'deny',
|
|
80
79
|
});
|
|
81
80
|
const result = manager.evaluate('SomeUnknownTool', {});
|
|
82
|
-
expect(result.behavior).toBe('
|
|
81
|
+
expect(result.behavior).toBe('allow');
|
|
83
82
|
});
|
|
84
83
|
it('first matching rule wins', () => {
|
|
85
84
|
const manager = new ToolApprovalManager({
|
|
@@ -175,24 +174,22 @@ describe('ToolApprovalManager', () => {
|
|
|
175
174
|
const manager = new ToolApprovalManager();
|
|
176
175
|
expect(await manager.removeRule('nonexistent')).toBe(false);
|
|
177
176
|
});
|
|
178
|
-
it('setPolicy replaces entire policy', async () => {
|
|
177
|
+
it('setPolicy replaces entire policy and normalizes defaultAction to allow', async () => {
|
|
179
178
|
const manager = new ToolApprovalManager();
|
|
180
179
|
await manager.setPolicy({
|
|
181
180
|
rules: [{ id: 'only', toolPattern: '*', action: 'deny' }],
|
|
182
|
-
defaultAction: '
|
|
181
|
+
defaultAction: 'allow',
|
|
183
182
|
enabled: true,
|
|
184
183
|
});
|
|
185
184
|
expect(manager.getPolicy().rules).toHaveLength(1);
|
|
186
|
-
expect(manager.getPolicy().defaultAction).toBe('
|
|
185
|
+
expect(manager.getPolicy().defaultAction).toBe('allow');
|
|
187
186
|
});
|
|
188
|
-
it('setDefaultAction
|
|
187
|
+
it('setDefaultAction rejects deny', async () => {
|
|
189
188
|
const manager = new ToolApprovalManager({
|
|
190
189
|
rules: [],
|
|
191
190
|
enabled: true,
|
|
192
|
-
defaultAction: 'allow',
|
|
193
191
|
});
|
|
194
|
-
await manager.setDefaultAction('deny');
|
|
195
|
-
expect(manager.evaluate('Unknown', {}).behavior).toBe('deny');
|
|
192
|
+
await expect(manager.setDefaultAction('deny')).rejects.toThrow(/defaultAction cannot be deny/);
|
|
196
193
|
});
|
|
197
194
|
it('setEnabled toggles the manager', async () => {
|
|
198
195
|
const manager = new ToolApprovalManager({
|
|
@@ -205,7 +202,6 @@ describe('ToolApprovalManager', () => {
|
|
|
205
202
|
},
|
|
206
203
|
],
|
|
207
204
|
enabled: true,
|
|
208
|
-
defaultAction: 'deny',
|
|
209
205
|
});
|
|
210
206
|
expect(manager.evaluate('Read', {}).behavior).toBe('deny');
|
|
211
207
|
await manager.setEnabled(false);
|
|
@@ -186,7 +186,11 @@ export interface ToolApprovalRule {
|
|
|
186
186
|
}
|
|
187
187
|
export interface ToolApprovalPolicy {
|
|
188
188
|
rules: ToolApprovalRule[];
|
|
189
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Always `allow`: tools that do not match any rule are allowed.
|
|
191
|
+
* Blocking is done only with explicit `deny` rules (deny-list contract).
|
|
192
|
+
*/
|
|
193
|
+
defaultAction: 'allow';
|
|
190
194
|
defaultDenyMessage?: string;
|
|
191
195
|
enabled: boolean;
|
|
192
196
|
}
|