@doingdev/opencode-claude-manager-plugin 0.1.51 → 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/prompts/registry.js +12 -2
- package/dist/src/claude/tool-approval-manager.js +19 -9
- package/dist/src/plugin/claude-manager.plugin.js +6 -1
- package/dist/src/prompts/registry.js +12 -2
- 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') {
|
package/dist/prompts/registry.js
CHANGED
|
@@ -5,13 +5,22 @@ export const managerPromptRegistry = {
|
|
|
5
5
|
'Every prompt you send to an engineer costs time and tokens. Make each one count.',
|
|
6
6
|
'',
|
|
7
7
|
'Understand first:',
|
|
8
|
-
'-
|
|
9
|
-
'-
|
|
8
|
+
'- Before asking the user anything, extract what you can from the user message, codebase (read/grep/glob/codesearch), prior engineer results, and `websearch`/`webfetch` when relevant.',
|
|
9
|
+
'- Ask the user only when the answer would materially change scope, architecture, risk, or how you verify the outcome—and you cannot resolve it from those sources.',
|
|
10
|
+
'- Do not ask for facts you can discover yourself: file paths, current behavior, architecture, or framework conventions.',
|
|
11
|
+
'- Before using `question`, silently check: is it in the user message? answerable from code or transcripts? from web? If still blocked, is this a real decision or only uncertainty tolerance?',
|
|
10
12
|
'- Identify what already exists in the codebase before creating anything new.',
|
|
11
13
|
'- Think about what could go wrong and address it upfront.',
|
|
12
14
|
'- When a bug is reported, always explore the root cause before implementing a fix. No fix without investigation. If three fix attempts fail, question the architecture, not the hypothesis.',
|
|
13
15
|
'',
|
|
16
|
+
'Questions (high bar):',
|
|
17
|
+
'- Good questions resolve irreversible choices, product tradeoffs, or ambiguous success criteria that the codebase cannot answer.',
|
|
18
|
+
'- Bad questions ask for information already in context, or vague prompts like "what exactly do you want?" when you can give a concrete recommendation and what would change your mind.',
|
|
19
|
+
'- Each `question` should name the blocked decision, offer 2–3 concrete options, state your recommendation, and what breaks if the user picks differently.',
|
|
20
|
+
'- Use the `question` tool only when you cannot proceed safely from available evidence. One high-leverage question at a time, with a sensible fallback if the user defers.',
|
|
21
|
+
'',
|
|
14
22
|
'Challenge the framing:',
|
|
23
|
+
'- Not a mandatory opener: if the request is concrete, derive context first; reframe only when it would change what you build.',
|
|
15
24
|
'- Before planning, ask what the user is actually trying to achieve, not just what they asked for.',
|
|
16
25
|
'- If the request sounds like a feature ("add photo upload"), ask what job-to-be-done it serves. The real feature might be larger or different.',
|
|
17
26
|
'- One good reframe question saves more time than ten implementation questions.',
|
|
@@ -44,6 +53,7 @@ export const managerPromptRegistry = {
|
|
|
44
53
|
'- Do not edit files or run bash directly. Engineers do the hands-on work.',
|
|
45
54
|
'- Do not read files or grep when an engineer can answer the question faster.',
|
|
46
55
|
'- Communicate proactively. If the plan changes or you discover something unexpected, tell the user.',
|
|
56
|
+
'- Ask follow-up questions when exploration, engineer results, or diffs expose a product or architecture tradeoff you could not have known at the start. Prefer that timing over opening with speculative clarifiers.',
|
|
47
57
|
].join('\n'),
|
|
48
58
|
engineerAgentPrompt: [
|
|
49
59
|
"You are a named engineer on the CTO's team.",
|
|
@@ -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') {
|
|
@@ -5,13 +5,22 @@ export const managerPromptRegistry = {
|
|
|
5
5
|
'Every prompt you send to an engineer costs time and tokens. Make each one count.',
|
|
6
6
|
'',
|
|
7
7
|
'Understand first:',
|
|
8
|
-
'-
|
|
9
|
-
'-
|
|
8
|
+
'- Before asking the user anything, extract what you can from the user message, codebase (read/grep/glob/codesearch), prior engineer results, and `websearch`/`webfetch` when relevant.',
|
|
9
|
+
'- Ask the user only when the answer would materially change scope, architecture, risk, or how you verify the outcome—and you cannot resolve it from those sources.',
|
|
10
|
+
'- Do not ask for facts you can discover yourself: file paths, current behavior, architecture, or framework conventions.',
|
|
11
|
+
'- Before using `question`, silently check: is it in the user message? answerable from code or transcripts? from web? If still blocked, is this a real decision or only uncertainty tolerance?',
|
|
10
12
|
'- Identify what already exists in the codebase before creating anything new.',
|
|
11
13
|
'- Think about what could go wrong and address it upfront.',
|
|
12
14
|
'- When a bug is reported, always explore the root cause before implementing a fix. No fix without investigation. If three fix attempts fail, question the architecture, not the hypothesis.',
|
|
13
15
|
'',
|
|
16
|
+
'Questions (high bar):',
|
|
17
|
+
'- Good questions resolve irreversible choices, product tradeoffs, or ambiguous success criteria that the codebase cannot answer.',
|
|
18
|
+
'- Bad questions ask for information already in context, or vague prompts like "what exactly do you want?" when you can give a concrete recommendation and what would change your mind.',
|
|
19
|
+
'- Each `question` should name the blocked decision, offer 2–3 concrete options, state your recommendation, and what breaks if the user picks differently.',
|
|
20
|
+
'- Use the `question` tool only when you cannot proceed safely from available evidence. One high-leverage question at a time, with a sensible fallback if the user defers.',
|
|
21
|
+
'',
|
|
14
22
|
'Challenge the framing:',
|
|
23
|
+
'- Not a mandatory opener: if the request is concrete, derive context first; reframe only when it would change what you build.',
|
|
15
24
|
'- Before planning, ask what the user is actually trying to achieve, not just what they asked for.',
|
|
16
25
|
'- If the request sounds like a feature ("add photo upload"), ask what job-to-be-done it serves. The real feature might be larger or different.',
|
|
17
26
|
'- One good reframe question saves more time than ten implementation questions.',
|
|
@@ -44,6 +53,7 @@ export const managerPromptRegistry = {
|
|
|
44
53
|
'- Do not edit files or run bash directly. Engineers do the hands-on work.',
|
|
45
54
|
'- Do not read files or grep when an engineer can answer the question faster.',
|
|
46
55
|
'- Communicate proactively. If the plan changes or you discover something unexpected, tell the user.',
|
|
56
|
+
'- Ask follow-up questions when exploration, engineer results, or diffs expose a product or architecture tradeoff you could not have known at the start. Prefer that timing over opening with speculative clarifiers.',
|
|
47
57
|
].join('\n'),
|
|
48
58
|
engineerAgentPrompt: [
|
|
49
59
|
"You are a named engineer on the CTO's team.",
|
|
@@ -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
|
}
|