mioku-plugin-agent 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.
@@ -0,0 +1,75 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AITool, Bot, MiokuContext } from "mioku";
4
+ import type { FsPolicy } from "./perm";
5
+ import { resolveWorkspacePath } from "./perm";
6
+ import { sendImageSource, sendLocalFile } from "../core/attachment";
7
+
8
+ interface DeliverToolDeps {
9
+ ctx: MiokuContext;
10
+ bot: Bot | undefined;
11
+ userId: number;
12
+ policy: FsPolicy;
13
+ }
14
+
15
+ export function createSendFileTool(deps: DeliverToolDeps): AITool {
16
+ return {
17
+ name: "send_file",
18
+ description:
19
+ "Send a local file to the user in the current private chat (documents, archives, generated artifacts).",
20
+ parameters: {
21
+ type: "object",
22
+ properties: {
23
+ file_path: { type: "string", description: "Local file path to send" },
24
+ name: { type: "string", description: "Optional display file name" },
25
+ },
26
+ required: ["file_path"],
27
+ },
28
+ handler: async (args) => {
29
+ const filePath = resolveWorkspacePath(deps.policy, String(args?.file_path ?? ""));
30
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
31
+ return { error: `File not found: ${filePath}` };
32
+ }
33
+ if (!deps.bot) return { error: "bot is not connected" };
34
+ const name = args?.name ? String(args.name) : path.basename(filePath);
35
+ await sendLocalFile(
36
+ deps.ctx,
37
+ deps.bot,
38
+ { type: "private", user_id: deps.userId },
39
+ filePath,
40
+ name,
41
+ );
42
+ return { success: true, file: filePath, name };
43
+ },
44
+ };
45
+ }
46
+
47
+ export function createSendImageTool(deps: DeliverToolDeps): AITool {
48
+ return {
49
+ name: "send_image",
50
+ description:
51
+ "Send a local image file to the user in the current private chat as a picture message.",
52
+ parameters: {
53
+ type: "object",
54
+ properties: {
55
+ file_path: { type: "string", description: "Local image path (png/jpg/webp/gif)" },
56
+ },
57
+ required: ["file_path"],
58
+ },
59
+ handler: async (args) => {
60
+ const filePath = resolveWorkspacePath(deps.policy, String(args?.file_path ?? ""));
61
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
62
+ return { error: `File not found: ${filePath}` };
63
+ }
64
+ if (!deps.bot) return { error: "bot is not connected" };
65
+ const sent = await sendImageSource(
66
+ deps.ctx,
67
+ deps.bot,
68
+ { type: "private", user_id: deps.userId },
69
+ filePath,
70
+ );
71
+ if (!sent) return { error: `Failed to send image: ${filePath}` };
72
+ return { success: true, file: filePath };
73
+ },
74
+ };
75
+ }
package/tools/fs.ts ADDED
@@ -0,0 +1,296 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import type { AITool } from "mioku";
5
+ import type { FsPolicy } from "./perm";
6
+ import { canWrite, permissionDenied, resolveWorkspacePath } from "./perm";
7
+
8
+ const READ_DEFAULT_LIMIT = 500;
9
+ const READ_MAX_LIMIT = 2000;
10
+ const READ_MAX_BYTES = 256 * 1024;
11
+ const READ_MAX_LINE = 2000;
12
+ const LIST_MAX_RESULTS = 100;
13
+ const GREP_MAX_MATCHES = 250;
14
+
15
+ export interface FsToolDeps {
16
+ policy: FsPolicy;
17
+ }
18
+
19
+ function toTool(tool: AITool): AITool {
20
+ return tool;
21
+ }
22
+
23
+ function lineNumbered(lines: string[], offset: number): string {
24
+ return lines
25
+ .map((line, index) => {
26
+ const truncated =
27
+ line.length > READ_MAX_LINE
28
+ ? `${line.slice(0, READ_MAX_LINE)}… [line truncated]`
29
+ : line;
30
+ return `${String(offset + index).padStart(6)}\t${truncated}`;
31
+ })
32
+ .join("\n");
33
+ }
34
+
35
+ export function createReadTool(deps: FsToolDeps): AITool {
36
+ return toTool({
37
+ name: "read",
38
+ description:
39
+ "Read a UTF-8 text file and return numbered lines. Supports offset/limit windowing for large files.",
40
+ parameters: {
41
+ type: "object",
42
+ properties: {
43
+ file_path: { type: "string", description: "File path (absolute, or relative to the workspace)" },
44
+ offset: { type: "number", description: "1-based first line to return" },
45
+ limit: { type: "number", description: "Max lines to return (default 500, max 2000)" },
46
+ },
47
+ required: ["file_path"],
48
+ },
49
+ handler: async (args) => {
50
+ const filePath = resolveWorkspacePath(deps.policy, String(args?.file_path ?? ""));
51
+ let stat: fs.Stats;
52
+ try {
53
+ stat = await fsp.stat(filePath);
54
+ } catch {
55
+ return { error: `File not found: ${filePath}` };
56
+ }
57
+ if (!stat.isFile()) return { error: `Not a regular file: ${filePath}` };
58
+ const offset = Math.max(1, Math.floor(Number(args?.offset) || 1));
59
+ let limit = Math.floor(Number(args?.limit) || READ_DEFAULT_LIMIT);
60
+ limit = Math.min(Math.max(1, limit), READ_MAX_LIMIT);
61
+ const content = await fsp.readFile(filePath, "utf-8");
62
+ const allLines = content.split("\n");
63
+ const selected: string[] = [];
64
+ let bytes = 0;
65
+ let index = offset - 1;
66
+ while (index < allLines.length && selected.length < limit) {
67
+ const line = `${allLines[index]}\n`;
68
+ if (bytes + line.length > READ_MAX_BYTES) {
69
+ selected.push("[output truncated: byte limit reached]");
70
+ break;
71
+ }
72
+ selected.push(allLines[index]);
73
+ bytes += line.length;
74
+ index += 1;
75
+ }
76
+ return {
77
+ file: filePath,
78
+ totalLines: allLines.length,
79
+ offset,
80
+ content: lineNumbered(selected, offset),
81
+ hasMore: index < allLines.length,
82
+ };
83
+ },
84
+ });
85
+ }
86
+
87
+ export function createWriteTool(deps: FsToolDeps): AITool {
88
+ return toTool({
89
+ name: "write",
90
+ description:
91
+ "Create or completely overwrite a UTF-8 text file with the given content.",
92
+ parameters: {
93
+ type: "object",
94
+ properties: {
95
+ file_path: { type: "string", description: "File path" },
96
+ content: { type: "string", description: "Full file content to write" },
97
+ },
98
+ required: ["file_path", "content"],
99
+ },
100
+ handler: async (args) => {
101
+ const filePath = resolveWorkspacePath(deps.policy, String(args?.file_path ?? ""));
102
+ if (!canWrite(deps.policy, filePath)) {
103
+ return { error: permissionDenied(deps.policy, "write", filePath).message };
104
+ }
105
+ const content = String(args?.content ?? "");
106
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
107
+ await fsp.writeFile(filePath, content, "utf-8");
108
+ return { success: true, file: filePath, bytes: Buffer.byteLength(content) };
109
+ },
110
+ });
111
+ }
112
+
113
+ export function createEditTool(deps: FsToolDeps): AITool {
114
+ return toTool({
115
+ name: "edit",
116
+ description:
117
+ "Replace an exact literal string in an existing UTF-8 text file. old_string must appear exactly once unless replace_all is set.",
118
+ parameters: {
119
+ type: "object",
120
+ properties: {
121
+ file_path: { type: "string", description: "File path" },
122
+ old_string: { type: "string", description: "Exact text to replace" },
123
+ new_string: { type: "string", description: "Replacement text" },
124
+ replace_all: { type: "boolean", description: "Replace every occurrence" },
125
+ },
126
+ required: ["file_path", "old_string", "new_string"],
127
+ },
128
+ handler: async (args) => {
129
+ const filePath = resolveWorkspacePath(deps.policy, String(args?.file_path ?? ""));
130
+ if (!canWrite(deps.policy, filePath)) {
131
+ return { error: permissionDenied(deps.policy, "edit", filePath).message };
132
+ }
133
+ const oldString = String(args?.old_string ?? "");
134
+ const newString = String(args?.new_string ?? "");
135
+ if (!oldString) return { error: "old_string must be a non-empty string" };
136
+ let content: string;
137
+ try {
138
+ content = await fsp.readFile(filePath, "utf-8");
139
+ } catch {
140
+ return { error: `File not found: ${filePath}` };
141
+ }
142
+ const occurrences = content.split(oldString).length - 1;
143
+ if (occurrences === 0) return { error: "old_string not found in file" };
144
+ if (occurrences > 1 && !args?.replace_all) {
145
+ return {
146
+ error: `old_string appears ${occurrences} times. Provide more surrounding context or set replace_all=true.`,
147
+ };
148
+ }
149
+ const next =
150
+ occurrences > 1 && args?.replace_all
151
+ ? content.split(oldString).join(newString)
152
+ : content.replace(oldString, newString);
153
+ await fsp.writeFile(filePath, next, "utf-8");
154
+ return { success: true, file: filePath, replacements: occurrences > 1 && args?.replace_all ? occurrences : 1 };
155
+ },
156
+ });
157
+ }
158
+
159
+ function patternToRegExp(pattern: string): RegExp {
160
+ let source = "";
161
+ for (let i = 0; i < pattern.length; i++) {
162
+ const char = pattern[i];
163
+ if (char === "*") {
164
+ if (pattern[i + 1] === "*") {
165
+ source += pattern[i + 2] === "/" ? "(?:.*/)?" : ".*";
166
+ if (pattern[i + 2] === "/") i += 2;
167
+ else i += 1;
168
+ } else {
169
+ source += "[^/]*";
170
+ }
171
+ continue;
172
+ }
173
+ if (char === "?") {
174
+ source += "[^/]";
175
+ continue;
176
+ }
177
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
178
+ }
179
+ return new RegExp(`^${source}$`);
180
+ }
181
+
182
+ async function walkFiles(root: string, cap: number): Promise<string[]> {
183
+ const results: string[] = [];
184
+ const queue = [root];
185
+ while (queue.length > 0 && results.length < cap * 20) {
186
+ const current = queue.shift()!;
187
+ let entries: fs.Dirent[];
188
+ try {
189
+ entries = await fsp.readdir(current, { withFileTypes: true });
190
+ } catch {
191
+ continue;
192
+ }
193
+ for (const entry of entries) {
194
+ const full = path.join(current, entry.name);
195
+ if (entry.isDirectory()) {
196
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
197
+ queue.push(full);
198
+ } else if (entry.isFile()) {
199
+ results.push(full);
200
+ if (results.length >= cap * 20) break;
201
+ }
202
+ }
203
+ }
204
+ return results;
205
+ }
206
+
207
+ export function createGlobTool(deps: FsToolDeps): AITool {
208
+ return toTool({
209
+ name: "glob",
210
+ description:
211
+ "Find files whose paths match a glob pattern (supports ** and *). Searches the workspace by default.",
212
+ parameters: {
213
+ type: "object",
214
+ properties: {
215
+ pattern: { type: "string", description: "Glob pattern, e.g. **/*.ts" },
216
+ path: { type: "string", description: "Directory to search (default: workspace root)" },
217
+ },
218
+ required: ["pattern"],
219
+ },
220
+ handler: async (args) => {
221
+ const pattern = String(args?.pattern ?? "");
222
+ if (!pattern) return { error: "pattern must be a non-empty string" };
223
+ const root = resolveWorkspacePath(deps.policy, String(args?.path ?? "") || deps.policy.workspaceRoot);
224
+ if (!fs.existsSync(root)) return { error: `Directory not found: ${root}` };
225
+ const regExp = patternToRegExp(pattern);
226
+ const files = await walkFiles(root, LIST_MAX_RESULTS);
227
+ const matched = files
228
+ .filter((file) => {
229
+ const rel = path.relative(root, file).split(path.sep).join("/");
230
+ return regExp.test(rel) || regExp.test(path.basename(file));
231
+ })
232
+ .slice(0, LIST_MAX_RESULTS);
233
+ return { root, count: matched.length, files: matched };
234
+ },
235
+ });
236
+ }
237
+
238
+ export function createGrepTool(deps: FsToolDeps): AITool {
239
+ return toTool({
240
+ name: "grep",
241
+ description:
242
+ "Search file contents with a regular expression. Returns matching lines with line numbers.",
243
+ parameters: {
244
+ type: "object",
245
+ properties: {
246
+ pattern: { type: "string", description: "Regular expression" },
247
+ path: { type: "string", description: "File or directory to search (default: workspace root)" },
248
+ include: { type: "string", description: "Glob filter for file names, e.g. *.ts" },
249
+ },
250
+ required: ["pattern"],
251
+ },
252
+ handler: async (args) => {
253
+ const pattern = String(args?.pattern ?? "");
254
+ if (!pattern) return { error: "pattern must be a non-empty string" };
255
+ let regExp: RegExp;
256
+ try {
257
+ regExp = new RegExp(pattern);
258
+ } catch (err) {
259
+ return { error: `Invalid regex: ${err}` };
260
+ }
261
+ const target = resolveWorkspacePath(deps.policy, String(args?.path ?? "") || deps.policy.workspaceRoot);
262
+ const include = args?.include ? patternToRegExp(String(args.include)) : null;
263
+ const stat = fs.existsSync(target) ? fs.statSync(target) : null;
264
+ const files = stat?.isFile() ? [target] : await walkFiles(target, GREP_MAX_MATCHES);
265
+ const matches: Array<{ file: string; line: number; text: string }> = [];
266
+ for (const file of files) {
267
+ if (include && !include.test(path.basename(file))) continue;
268
+ let content: string;
269
+ try {
270
+ if (fs.statSync(file).size > 2 * 1024 * 1024) continue;
271
+ content = await fsp.readFile(file, "utf-8");
272
+ } catch {
273
+ continue;
274
+ }
275
+ const lines = content.split("\n");
276
+ for (let i = 0; i < lines.length; i++) {
277
+ if (regExp.test(lines[i])) {
278
+ matches.push({
279
+ file,
280
+ line: i + 1,
281
+ text: lines[i].slice(0, 500),
282
+ });
283
+ if (matches.length >= GREP_MAX_MATCHES) break;
284
+ }
285
+ }
286
+ if (matches.length >= GREP_MAX_MATCHES) break;
287
+ }
288
+ return {
289
+ pattern,
290
+ count: matches.length,
291
+ truncated: matches.length >= GREP_MAX_MATCHES,
292
+ matches,
293
+ };
294
+ },
295
+ });
296
+ }
package/tools/index.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type { AITool, Bot, SessionToolDefinition } from "mioku";
2
+ import type { AgentHost } from "../types";
3
+ import type { BashReporter } from "./bash";
4
+ import type { FsToolDeps } from "./fs";
5
+ import {
6
+ createEditTool,
7
+ createGlobTool,
8
+ createGrepTool,
9
+ createReadTool,
10
+ createWriteTool,
11
+ } from "./fs";
12
+ import { createBashTool } from "./bash";
13
+ import { createSendFileTool, createSendImageTool } from "./deliver";
14
+ import { createViewImageTool } from "./view-image";
15
+ import { createWebFetchTool, createWebSearchTool } from "./web";
16
+ import { createTodoTool } from "./todo";
17
+ import {
18
+ isAutoReviewMode,
19
+ isQuietMode,
20
+ normalizePermissionLevel,
21
+ type FsPolicy,
22
+ } from "./perm";
23
+ import { describeImageFile } from "../core/media";
24
+ import { assessCommandRisk } from "../core/risk";
25
+ import type { TurnActivity } from "../core/activity";
26
+
27
+ export interface TurnToolOptions {
28
+ userId: number;
29
+ bot: Bot | undefined;
30
+ runId: number;
31
+ reporter: BashReporter;
32
+ activity: TurnActivity;
33
+ }
34
+
35
+ function isToolError(result: unknown): boolean {
36
+ if (!result || typeof result !== "object") return false;
37
+ const record = result as Record<string, unknown>;
38
+ return Boolean(record.error) || record.success === false;
39
+ }
40
+
41
+ function wrapTool(
42
+ tool: AITool,
43
+ host: AgentHost,
44
+ runId: number,
45
+ activity: TurnActivity,
46
+ ): AITool {
47
+ const recording = host.getSettings().dataCollection.enabled && runId > 0;
48
+ const tracking = tool.name !== "bash";
49
+ if (!recording && !tracking) return tool;
50
+ return {
51
+ ...tool,
52
+ handler: async (args) => {
53
+ const startedAt = Date.now();
54
+ try {
55
+ const result = await tool.handler(args);
56
+ if (recording) {
57
+ host.db.recordToolCall(
58
+ runId,
59
+ tool.name,
60
+ args,
61
+ JSON.stringify(result ?? {}).length,
62
+ Date.now() - startedAt,
63
+ !isToolError(result),
64
+ );
65
+ }
66
+ if (tracking) {
67
+ activity.recordTool(
68
+ tool.name,
69
+ args ?? {},
70
+ result,
71
+ Date.now() - startedAt,
72
+ );
73
+ }
74
+ return result;
75
+ } catch (err) {
76
+ if (recording) {
77
+ host.db.recordToolCall(
78
+ runId,
79
+ tool.name,
80
+ args,
81
+ 0,
82
+ Date.now() - startedAt,
83
+ false,
84
+ String(err),
85
+ );
86
+ }
87
+ if (tracking) {
88
+ activity.recordTool(
89
+ tool.name,
90
+ args ?? {},
91
+ { error: String(err) },
92
+ Date.now() - startedAt,
93
+ );
94
+ }
95
+ throw err;
96
+ }
97
+ },
98
+ };
99
+ }
100
+
101
+ export function buildTurnTools(
102
+ host: AgentHost,
103
+ options: TurnToolOptions,
104
+ ): { tools: SessionToolDefinition[]; webSearchState: { count: number } } {
105
+ const base = host.getBase();
106
+ const settings = host.getSettings();
107
+ const policy: FsPolicy = {
108
+ level: normalizePermissionLevel(base.permissionLevel),
109
+ workspaceRoot: host.workspaceRoot(options.userId),
110
+ };
111
+ const webSearchState = { count: 0 };
112
+ const resolved = host.resolveModel();
113
+
114
+ const fsDeps: FsToolDeps = { policy };
115
+
116
+ const tools: AITool[] = [
117
+ createReadTool(fsDeps),
118
+ createGlobTool(fsDeps),
119
+ createGrepTool(fsDeps),
120
+ createSendFileTool({
121
+ ctx: host.ctx,
122
+ bot: options.bot,
123
+ userId: options.userId,
124
+ policy,
125
+ }),
126
+ createSendImageTool({
127
+ ctx: host.ctx,
128
+ bot: options.bot,
129
+ userId: options.userId,
130
+ policy,
131
+ }),
132
+ ];
133
+
134
+ if (policy.level !== "read-only") {
135
+ tools.push(createWriteTool(fsDeps), createEditTool(fsDeps));
136
+ }
137
+ if (resolved?.isMultimodal || resolved?.vision) {
138
+ tools.push(
139
+ createViewImageTool({
140
+ policy,
141
+ describeImage:
142
+ !resolved.isMultimodal && resolved.vision
143
+ ? (file: string) => describeImageFile(host, file)
144
+ : undefined,
145
+ }),
146
+ );
147
+ }
148
+ if (settings.bash.enabled) {
149
+ tools.push(
150
+ createBashTool({
151
+ userId: options.userId,
152
+ policy,
153
+ config: settings.bash,
154
+ approvals: host.approvals,
155
+ reporter: options.reporter,
156
+ assessRisk: isAutoReviewMode(policy.level)
157
+ ? (command, purpose) => assessCommandRisk(host, command, purpose)
158
+ : undefined,
159
+ }),
160
+ );
161
+ }
162
+ if (settings.webSearch.enabled) {
163
+ const searchTool = createWebSearchTool({
164
+ settings,
165
+ onSearch: () => {
166
+ webSearchState.count += 1;
167
+ },
168
+ });
169
+ tools.push(searchTool);
170
+ }
171
+ if (settings.webFetch.enabled) {
172
+ tools.push(createWebFetchTool({ settings }));
173
+ }
174
+ tools.push(
175
+ createTodoTool({
176
+ host,
177
+ userId: options.userId,
178
+ bot: options.bot,
179
+ quiet: isQuietMode(policy.level),
180
+ }),
181
+ );
182
+
183
+ // TODO(skills): load chat-style external skills via aiService.registerSkill.
184
+ // TODO(mcp): Model Context Protocol client for external tool servers.
185
+ // TODO(memory): hybrid retrieval (vector + BM25 + rerank) via the shared knowledge-base service.
186
+
187
+ return {
188
+ tools: tools.map((tool) => ({
189
+ name: tool.name,
190
+ tool: wrapTool(tool, host, options.runId, options.activity),
191
+ })),
192
+ webSearchState,
193
+ };
194
+ }
package/tools/perm.ts ADDED
@@ -0,0 +1,87 @@
1
+ import * as path from "node:path";
2
+ import type { AgentPermissionLevel } from "../types";
3
+
4
+ export const PERMISSION_LEVELS: readonly AgentPermissionLevel[] = [
5
+ "read-only",
6
+ "workspace-write",
7
+ "auto",
8
+ "full",
9
+ "yolo",
10
+ ];
11
+
12
+ export function normalizePermissionLevel(value: unknown): AgentPermissionLevel {
13
+ const raw = String(value ?? "")
14
+ .trim()
15
+ .toLowerCase();
16
+ if ((PERMISSION_LEVELS as readonly string[]).includes(raw)) {
17
+ return raw as AgentPermissionLevel;
18
+ }
19
+ return "workspace-write";
20
+ }
21
+
22
+ export interface FsPolicy {
23
+ level: AgentPermissionLevel;
24
+ workspaceRoot: string;
25
+ }
26
+
27
+ export function workspaceRootFor(baseDir: string, userId: number): string {
28
+ const raw = String(baseDir ?? "").trim();
29
+ const base =
30
+ raw && path.isAbsolute(raw)
31
+ ? raw
32
+ : path.resolve(
33
+ process.cwd(),
34
+ raw || path.join("data", "agent", "workspace"),
35
+ );
36
+ return path.resolve(base, String(userId));
37
+ }
38
+
39
+ export function resolveWorkspacePath(policy: FsPolicy, target: string): string {
40
+ const rawPath = String(target ?? "").trim();
41
+ if (!rawPath) throw new Error("path must be a non-empty string");
42
+ if (path.isAbsolute(rawPath)) return path.resolve(rawPath);
43
+ return path.resolve(policy.workspaceRoot, rawPath);
44
+ }
45
+
46
+ export function isInside(root: string, target: string): boolean {
47
+ const rel = path.relative(root, target);
48
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
49
+ }
50
+
51
+ export function canWrite(policy: FsPolicy, target: string): boolean {
52
+ if (policy.level === "read-only") return false;
53
+ if (policy.level === "workspace-write") {
54
+ return isInside(policy.workspaceRoot, target);
55
+ }
56
+ return true;
57
+ }
58
+
59
+ export function bashRequiresApproval(level: AgentPermissionLevel): boolean {
60
+ return level === "read-only" || level === "workspace-write";
61
+ }
62
+
63
+ export function isAutoReviewMode(level: AgentPermissionLevel): boolean {
64
+ return level === "auto";
65
+ }
66
+
67
+ export function isQuietMode(level: AgentPermissionLevel): boolean {
68
+ return level === "yolo";
69
+ }
70
+
71
+ export function batchesActivity(level: AgentPermissionLevel): boolean {
72
+ return level === "full";
73
+ }
74
+
75
+ export function permissionDenied(
76
+ policy: FsPolicy,
77
+ operation: string,
78
+ target: string,
79
+ ): Error {
80
+ const scope =
81
+ policy.level === "read-only"
82
+ ? "the session is read-only"
83
+ : `writes are restricted to the workspace (${policy.workspaceRoot})`;
84
+ return new Error(
85
+ `[permission denied] Cannot ${operation} ${target}: ${scope}.`,
86
+ );
87
+ }