@arnilo/prism-coding-agent 0.0.3 → 0.0.4

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.4] - 2026-07-14
11
+
12
+ ### Added
13
+
14
+ - `read` tool image bounds: `maxImageBytes` (default 10 MB), optional `transformImage` callback, `DEFAULT_MAX_IMAGE_BYTES`, and `ReadOperations.statFile` for stat-first rejection.
15
+
16
+ ### Changed
17
+
18
+ - Shell tools expose `exclusive: true`; all coding tools can apply host `ExecutionPolicy` checks before side effects.
19
+ - `autoResizeImages` on `read` is deprecated; it is ignored unless `transformImage` is also provided.
20
+ - Image read metadata now includes `image.bytes` and `image.resized` reflects whether `transformImage` ran.
21
+
10
22
  ## [0.0.3] - 2026-07-08
11
23
 
12
24
  ### Added
package/dist/edit.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * completed, the edit is real and is reported as success rather than a misleading "aborted".
21
21
  */
22
22
  import { Buffer } from "node:buffer";
23
- import type { ToolDefinition } from "@arnilo/prism";
23
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
24
24
  export interface Edit {
25
25
  oldText: string;
26
26
  newText: string;
@@ -47,6 +47,8 @@ export interface EditOperations {
47
47
  access: (absolutePath: string) => Promise<void>;
48
48
  }
49
49
  export interface EditToolOptions {
50
+ /** Structured pre-execution policy checked before filesystem writes. */
51
+ executionPolicy?: ExecutionPolicy;
50
52
  /** Custom operations backend (default: local filesystem). */
51
53
  operations?: EditOperations;
52
54
  }
package/dist/edit.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
+ import { enforceExecutionPolicy } from "./execution-policy.js";
3
4
  import { resolveToCwd } from "./path-utils.js";
4
5
  import { withFileMutationQueue } from "./file-mutation-queue.js";
5
6
  import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
@@ -95,12 +96,22 @@ export function createEditTool(cwd, options) {
95
96
  const edits = editsOrError;
96
97
  try {
97
98
  const absolutePath = resolveToCwd(prepared.path, cwd);
98
- return await withFileMutationQueue(absolutePath, async () => {
99
+ const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
100
+ kind: "edit",
101
+ operation: "edit",
102
+ paths: [absolutePath],
103
+ risk: "medium",
104
+ metadata: { editCount: edits.length, signal: context.signal },
105
+ }, toolCallId, "edit");
106
+ if (!policyCheck.allowed)
107
+ return policyCheck.result;
108
+ const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
109
+ return await withFileMutationQueue(allowedPath, async () => {
99
110
  if (context.signal?.aborted)
100
111
  return errorResult(toolCallId, "Operation aborted");
101
112
  // Check the file is readable + writable.
102
113
  try {
103
- await ops.access(absolutePath);
114
+ await ops.access(allowedPath);
104
115
  }
105
116
  catch (error) {
106
117
  if (context.signal?.aborted)
@@ -111,7 +122,7 @@ export function createEditTool(cwd, options) {
111
122
  }
112
123
  if (context.signal?.aborted)
113
124
  return errorResult(toolCallId, "Operation aborted");
114
- const buffer = await ops.readFile(absolutePath);
125
+ const buffer = await ops.readFile(allowedPath);
115
126
  if (context.signal?.aborted)
116
127
  return errorResult(toolCallId, "Operation aborted");
117
128
  const rawContent = buffer.toString("utf-8");
@@ -132,7 +143,7 @@ export function createEditTool(cwd, options) {
132
143
  if (context.signal?.aborted)
133
144
  return errorResult(toolCallId, "Operation aborted");
134
145
  const finalContent = bom + restoreLineEndings(newContent, originalEnding);
135
- await ops.writeFile(absolutePath, finalContent);
146
+ await ops.writeFile(allowedPath, finalContent);
136
147
  const diffResult = generateDiffString(baseContent, newContent);
137
148
  const patch = generateUnifiedPatch(prepared.path, baseContent, newContent);
138
149
  return {
@@ -0,0 +1,9 @@
1
+ import type { ExecutionAction, ExecutionPolicy } from "@arnilo/prism";
2
+ import type { ToolResult } from "@arnilo/prism";
3
+ export declare function enforceExecutionPolicy(policy: ExecutionPolicy | undefined, action: ExecutionAction, toolCallId: string, toolName: string): Promise<{
4
+ allowed: true;
5
+ action: ExecutionAction;
6
+ } | {
7
+ allowed: false;
8
+ result: ToolResult;
9
+ }>;
@@ -0,0 +1,26 @@
1
+ import { assertExecutionAllowed, ExecutionDeniedError } from "@arnilo/prism";
2
+ export async function enforceExecutionPolicy(policy, action, toolCallId, toolName) {
3
+ if (!policy)
4
+ return { allowed: true, action };
5
+ try {
6
+ const allowedAction = await assertExecutionAllowed(policy, action);
7
+ return { allowed: true, action: allowedAction };
8
+ }
9
+ catch (error) {
10
+ const message = error instanceof ExecutionDeniedError
11
+ ? error.decision.reason ?? error.message
12
+ : error instanceof Error
13
+ ? error.message
14
+ : String(error);
15
+ return {
16
+ allowed: false,
17
+ result: {
18
+ toolCallId,
19
+ name: toolName,
20
+ content: [{ type: "text", text: message }],
21
+ error: { message },
22
+ },
23
+ };
24
+ }
25
+ }
26
+ //# sourceMappingURL=execution-policy.js.map
package/dist/index.d.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  export { createShellTool, createLocalBashOperations, getShellConfig, killProcessTree, waitForChildProcess, } from "./shell.js";
2
2
  export type { ShellToolOptions, ShellConfig, BashOperations, BashExecOptions, BashSpawnContext, BashSpawnHook, } from "./shell.js";
3
- export { createReadTool, detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile, } from "./read.js";
4
- export type { ReadToolOptions, ReadOperations } from "./read.js";
3
+ export { createReadTool, detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile, DEFAULT_MAX_IMAGE_BYTES, } from "./read.js";
4
+ export type { ReadToolOptions, ReadOperations, TransformImage, TransformImageInput, } from "./read.js";
5
5
  export { createWriteTool } from "./write.js";
6
6
  export type { WriteToolOptions, WriteOperations } from "./write.js";
7
7
  export { createEditTool } from "./edit.js";
8
8
  export type { EditToolOptions, EditOperations, EditToolDetails, Edit } from "./edit.js";
9
9
  export { withFileMutationQueue } from "./file-mutation-queue.js";
10
- import type { ToolDefinition } from "@arnilo/prism";
10
+ export { enforceExecutionPolicy } from "./execution-policy.js";
11
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
11
12
  import type { ShellToolOptions } from "./shell.js";
12
13
  import type { ReadToolOptions } from "./read.js";
13
14
  import type { WriteToolOptions } from "./write.js";
14
15
  import type { EditToolOptions } from "./edit.js";
15
16
  /** Per-tool options combined for the aggregator factories. */
16
17
  export interface ToolsOptions {
18
+ /** Shared execution policy applied to every coding tool unless overridden per tool. */
19
+ executionPolicy?: ExecutionPolicy;
17
20
  shell?: ShellToolOptions;
18
21
  read?: ReadToolOptions;
19
22
  write?: WriteToolOptions;
package/dist/index.js CHANGED
@@ -5,24 +5,31 @@
5
5
  // `createToolRegistry(createCodingTools(cwd))`). No tools are auto-registered — import what you need.
6
6
  // --- per-tool factories & types ---
7
7
  export { createShellTool, createLocalBashOperations, getShellConfig, killProcessTree, waitForChildProcess, } from "./shell.js";
8
- export { createReadTool, detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile, } from "./read.js";
8
+ export { createReadTool, detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile, DEFAULT_MAX_IMAGE_BYTES, } from "./read.js";
9
9
  export { createWriteTool } from "./write.js";
10
10
  export { createEditTool } from "./edit.js";
11
11
  // --- generic primitives (re-exported for hosts that want them) ---
12
12
  export { withFileMutationQueue } from "./file-mutation-queue.js";
13
+ export { enforceExecutionPolicy } from "./execution-policy.js";
13
14
  import { createShellTool } from "./shell.js";
14
15
  import { createReadTool } from "./read.js";
15
16
  import { createWriteTool } from "./write.js";
16
17
  import { createEditTool } from "./edit.js";
18
+ function withSharedExecutionPolicy(toolOptions, shared) {
19
+ if (!shared)
20
+ return (toolOptions ?? {});
21
+ return { ...(toolOptions ?? {}), executionPolicy: toolOptions?.executionPolicy ?? shared };
22
+ }
17
23
  /**
18
24
  * The four coding tools: `shell`, `read`, `write`, `edit`. Register all of them for a coding agent.
19
25
  */
20
26
  export function createCodingTools(cwd, options) {
27
+ const policy = options?.executionPolicy;
21
28
  return [
22
- createShellTool(cwd, options?.shell),
23
- createReadTool(cwd, options?.read),
24
- createWriteTool(cwd, options?.write),
25
- createEditTool(cwd, options?.edit),
29
+ createShellTool(cwd, withSharedExecutionPolicy(options?.shell, policy)),
30
+ createReadTool(cwd, withSharedExecutionPolicy(options?.read, policy)),
31
+ createWriteTool(cwd, withSharedExecutionPolicy(options?.write, policy)),
32
+ createEditTool(cwd, withSharedExecutionPolicy(options?.edit, policy)),
26
33
  ];
27
34
  }
28
35
  /** Read-only subset: `read` only (this package ships no grep/find/ls). */
package/dist/read.d.ts CHANGED
@@ -8,11 +8,11 @@
8
8
  * the model-aware non-vision note (Prism's `ToolExecutionContext` has no model field).
9
9
  *
10
10
  * Deviations from pi (documented):
11
- * - **`autoResizeImages` is a documented no-op** (deferred). pi resizes images to ≤2000×2000 via a
12
- * photon/WASM + `worker_threads` helper (`utils/image-process.js`); pulling that in adds native
13
- * build weight for a display-size concern hosts can own. The tool returns the raw image bytes as
14
- * base64 `ImageContent`. `ponytail:` ceiling noted inline; upgrade path = port image processing
15
- * when a host needs context-size capping.
11
+ * - **Image resize is host-owned.** pi resizes images to ≤2000×2000 via a photon/WASM +
12
+ * `worker_threads` helper (`utils/image-process.js`); this package rejects oversize images by
13
+ * `stat`/`buffer.length` against `maxImageBytes` and accepts an optional `transformImage`
14
+ * callback for host-provided resizing. `autoResizeImages` is deprecated it only takes effect
15
+ * when paired with `transformImage`.
16
16
  * - Abort + all read failures return a Prism `error` result (pi throws/rejects). Prism's
17
17
  * `dispatchToolCall` would catch a throw anyway, but returning a clean error result is predictable
18
18
  * for direct-`execute` callers and matches the package's `shell` tool.
@@ -20,12 +20,21 @@
20
20
  * named `shell`.
21
21
  */
22
22
  import { Buffer } from "node:buffer";
23
- import type { ToolDefinition } from "@arnilo/prism";
23
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
24
24
  import { type TruncationResult } from "./truncate.js";
25
25
  /** Detect a supported image MIME type from a buffer's leading bytes. Returns null for non-images. */
26
26
  export declare function detectSupportedImageMimeType(buffer: Buffer): string | null;
27
27
  /** Sniff the leading bytes of a file and return its image MIME type (null if not a supported image). */
28
28
  export declare function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null>;
29
+ /** Default maximum image file size before read/transform (10 MB). */
30
+ export declare const DEFAULT_MAX_IMAGE_BYTES = 10000000;
31
+ /** Input passed to an optional host-owned image transformer. */
32
+ export interface TransformImageInput {
33
+ readonly buffer: Buffer;
34
+ readonly mimeType: string;
35
+ }
36
+ /** Host callback to resize or re-encode an image before base64 encoding. */
37
+ export type TransformImage = (input: TransformImageInput) => Promise<Buffer>;
29
38
  /**
30
39
  * Pluggable operations for the read tool. Override to delegate file reading to remote systems
31
40
  * (e.g. SSH) while keeping the tool's truncation/offset/limit behavior.
@@ -35,15 +44,25 @@ export interface ReadOperations {
35
44
  readFile: (absolutePath: string) => Promise<Buffer>;
36
45
  /** Check the file is readable (throw if not). */
37
46
  access: (absolutePath: string) => Promise<void>;
47
+ /** Return file size in bytes for image bound checks (default: local `fs.stat`). */
48
+ statFile?: (absolutePath: string) => Promise<{
49
+ size: number;
50
+ }>;
38
51
  /** Detect image MIME type from the file; return null/undefined for non-images. */
39
52
  detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
40
53
  }
41
54
  export interface ReadToolOptions {
55
+ /** Structured pre-execution policy checked before filesystem access. */
56
+ executionPolicy?: ExecutionPolicy;
42
57
  /**
43
- * Whether to auto-resize images (pi resizes to ≤2000×2000). **Not yet implemented** — the tool
44
- * returns raw image bytes. Kept as a placeholder so hosts can opt in once resizing is ported.
58
+ * @deprecated Use `transformImage` instead. When `transformImage` is absent this flag is ignored.
59
+ * When both are set, `transformImage` runs and `image.resized` is `true` on success.
45
60
  */
46
61
  autoResizeImages?: boolean;
62
+ /** Reject image reads larger than this many bytes (default {@link DEFAULT_MAX_IMAGE_BYTES}). */
63
+ maxImageBytes?: number;
64
+ /** Optional host callback to resize or re-encode images before base64 encoding. */
65
+ transformImage?: TransformImage;
47
66
  /** Custom operations backend (default: local filesystem). */
48
67
  operations?: ReadOperations;
49
68
  /** Max lines kept from the head (default 2000). */
package/dist/read.js CHANGED
@@ -8,11 +8,11 @@
8
8
  * the model-aware non-vision note (Prism's `ToolExecutionContext` has no model field).
9
9
  *
10
10
  * Deviations from pi (documented):
11
- * - **`autoResizeImages` is a documented no-op** (deferred). pi resizes images to ≤2000×2000 via a
12
- * photon/WASM + `worker_threads` helper (`utils/image-process.js`); pulling that in adds native
13
- * build weight for a display-size concern hosts can own. The tool returns the raw image bytes as
14
- * base64 `ImageContent`. `ponytail:` ceiling noted inline; upgrade path = port image processing
15
- * when a host needs context-size capping.
11
+ * - **Image resize is host-owned.** pi resizes images to ≤2000×2000 via a photon/WASM +
12
+ * `worker_threads` helper (`utils/image-process.js`); this package rejects oversize images by
13
+ * `stat`/`buffer.length` against `maxImageBytes` and accepts an optional `transformImage`
14
+ * callback for host-provided resizing. `autoResizeImages` is deprecated it only takes effect
15
+ * when paired with `transformImage`.
16
16
  * - Abort + all read failures return a Prism `error` result (pi throws/rejects). Prism's
17
17
  * `dispatchToolCall` would catch a throw anyway, but returning a clean error result is predictable
18
18
  * for direct-`execute` callers and matches the package's `shell` tool.
@@ -21,7 +21,8 @@
21
21
  */
22
22
  import { Buffer } from "node:buffer";
23
23
  import { constants } from "node:fs";
24
- import { access as fsAccess, open, readFile as fsReadFile } from "node:fs/promises";
24
+ import { access as fsAccess, open, readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
25
+ import { enforceExecutionPolicy } from "./execution-policy.js";
25
26
  import { resolveReadPathAsync } from "./path-utils.js";
26
27
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, } from "./truncate.js";
27
28
  // --- magic-byte image MIME detection (faithful port of pi utils/mime.js, pure JS, no deps) ---
@@ -140,11 +141,51 @@ function startsWithAscii(buffer, offset, text) {
140
141
  }
141
142
  return true;
142
143
  }
144
+ // --- read tool ---
145
+ /** Default maximum image file size before read/transform (10 MB). */
146
+ export const DEFAULT_MAX_IMAGE_BYTES = 10_000_000;
143
147
  const defaultReadOperations = {
144
148
  readFile: (path) => fsReadFile(path),
145
149
  access: (path) => fsAccess(path, constants.R_OK),
150
+ statFile: async (path) => {
151
+ const info = await fsStat(path);
152
+ return { size: info.size };
153
+ },
146
154
  detectImageMimeType: detectSupportedImageMimeTypeFromFile,
147
155
  };
156
+ function imageSizeError(actualBytes, maxImageBytes) {
157
+ return `Image file is ${formatSize(actualBytes)}, exceeds ${formatSize(maxImageBytes)} limit.`;
158
+ }
159
+ async function loadImageBuffer(absolutePath, mimeType, ops, options) {
160
+ if (options.signal?.aborted) {
161
+ throw new Error("Operation aborted");
162
+ }
163
+ if (ops.statFile) {
164
+ const { size } = await ops.statFile(absolutePath);
165
+ if (size > options.maxImageBytes) {
166
+ throw new Error(imageSizeError(size, options.maxImageBytes));
167
+ }
168
+ }
169
+ let buffer = await ops.readFile(absolutePath);
170
+ if (buffer.length > options.maxImageBytes) {
171
+ throw new Error(imageSizeError(buffer.length, options.maxImageBytes));
172
+ }
173
+ let resized = false;
174
+ if (options.transformImage) {
175
+ if (options.signal?.aborted) {
176
+ throw new Error("Operation aborted");
177
+ }
178
+ buffer = await options.transformImage({ buffer, mimeType });
179
+ resized = true;
180
+ if (buffer.length > options.maxImageBytes) {
181
+ throw new Error(`Transformed image is ${formatSize(buffer.length)}, exceeds ${formatSize(options.maxImageBytes)} limit.`);
182
+ }
183
+ }
184
+ else if (options.autoResizeImages) {
185
+ // Deprecated flag without a transformer — intentionally ignored for backward compatibility.
186
+ }
187
+ return { buffer, resized };
188
+ }
148
189
  function errorResult(toolCallId, message) {
149
190
  return {
150
191
  toolCallId,
@@ -154,9 +195,10 @@ function errorResult(toolCallId, message) {
154
195
  };
155
196
  }
156
197
  export function createReadTool(cwd, options) {
157
- const ops = options?.operations ?? defaultReadOperations;
198
+ const ops = { ...defaultReadOperations, ...options?.operations };
158
199
  const maxLines = options?.maxLines ?? DEFAULT_MAX_LINES;
159
200
  const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
201
+ const maxImageBytes = options?.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES;
160
202
  return {
161
203
  name: "read",
162
204
  description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp); images are returned as image content. For text files, output is truncated to ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
@@ -183,12 +225,25 @@ export function createReadTool(cwd, options) {
183
225
  }
184
226
  try {
185
227
  const absolutePath = await resolveReadPathAsync(path, cwd);
186
- await ops.access(absolutePath);
187
- const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;
228
+ const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
229
+ kind: "read",
230
+ operation: "read",
231
+ paths: [absolutePath],
232
+ risk: "low",
233
+ metadata: { offset, limit, signal: context.signal },
234
+ }, toolCallId, "read");
235
+ if (!policyCheck.allowed)
236
+ return policyCheck.result;
237
+ const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
238
+ await ops.access(allowedPath);
239
+ const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(allowedPath) : undefined;
188
240
  if (mimeType) {
189
- const buffer = await ops.readFile(absolutePath);
190
- // ponytail: auto-resize deferred — raw bytes returned; port image processing (resize to
191
- // ≤2000×2000) when a host needs context-size capping. Until then large images bloat context.
241
+ const { buffer, resized } = await loadImageBuffer(allowedPath, mimeType, ops, {
242
+ maxImageBytes,
243
+ transformImage: options?.transformImage,
244
+ autoResizeImages: options?.autoResizeImages,
245
+ signal: context.signal,
246
+ });
192
247
  return {
193
248
  toolCallId,
194
249
  name: "read",
@@ -196,11 +251,11 @@ export function createReadTool(cwd, options) {
196
251
  { type: "text", text: `Read image file [${mimeType}]` },
197
252
  { type: "image", data: buffer.toString("base64"), mimeType },
198
253
  ],
199
- metadata: { image: { mimeType, resized: false } },
254
+ metadata: { image: { mimeType, resized, bytes: buffer.length } },
200
255
  };
201
256
  }
202
257
  // Text path: faithful port of pi's offset/limit → truncateHead → continuation logic.
203
- const buffer = await ops.readFile(absolutePath);
258
+ const buffer = await ops.readFile(allowedPath);
204
259
  const textContent = buffer.toString("utf-8");
205
260
  const allLines = textContent.split("\n");
206
261
  const totalFileLines = allLines.length;
package/dist/shell.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * (argv `-c` only). Default spawn env is `process.env` (no pi CLI binDir PATH injection).
22
22
  */
23
23
  import { type ChildProcess } from "node:child_process";
24
- import type { ToolDefinition } from "@arnilo/prism";
24
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
25
25
  export interface ShellConfig {
26
26
  shell: string;
27
27
  args: string[];
@@ -45,6 +45,8 @@ export interface BashOperations {
45
45
  }>;
46
46
  }
47
47
  export interface ShellToolOptions {
48
+ /** Structured pre-execution policy checked before spawn. */
49
+ executionPolicy?: ExecutionPolicy;
48
50
  /** Custom operations backend (default: local shell). Override to delegate to remote shells. */
49
51
  operations?: BashOperations;
50
52
  /** Command prefix prepended to every command (e.g. shell setup commands). */
package/dist/shell.js CHANGED
@@ -23,6 +23,7 @@
23
23
  import { spawn } from "node:child_process";
24
24
  import { constants, existsSync } from "node:fs";
25
25
  import { access as fsAccess } from "node:fs/promises";
26
+ import { enforceExecutionPolicy } from "./execution-policy.js";
26
27
  import { OutputAccumulator } from "./output-accumulator.js";
27
28
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "./truncate.js";
28
29
  const EXIT_STDIO_GRACE_MS = 100;
@@ -247,6 +248,7 @@ export function createShellTool(cwd, options) {
247
248
  const tempFilePrefix = options?.tempFilePrefix ?? "prism-shell";
248
249
  return {
249
250
  name: "shell",
251
+ exclusive: true,
250
252
  description: `Execute a shell command in the current working directory. Returns combined stdout and stderr. Output is truncated to the last ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
251
253
  parameters: {
252
254
  type: "object",
@@ -270,9 +272,22 @@ export function createShellTool(cwd, options) {
270
272
  };
271
273
  }
272
274
  const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
273
- const spawnContext = spawnHook
275
+ let spawnContext = spawnHook
274
276
  ? spawnHook({ command: resolvedCommand, cwd, env: { ...process.env } })
275
277
  : { command: resolvedCommand, cwd, env: { ...process.env } };
278
+ const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
279
+ kind: "shell",
280
+ operation: "execute",
281
+ command: spawnContext.command,
282
+ paths: [spawnContext.cwd],
283
+ risk: "high",
284
+ metadata: { timeout, signal: context.signal },
285
+ }, toolCallId, "shell");
286
+ if (!policyCheck.allowed)
287
+ return policyCheck.result;
288
+ if (policyCheck.action.command) {
289
+ spawnContext = { ...spawnContext, command: policyCheck.action.command };
290
+ }
276
291
  const output = new OutputAccumulator({ maxLines, maxBytes, tempFilePrefix });
277
292
  let acceptingOutput = true;
278
293
  const handleData = (data) => {
package/dist/write.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ToolDefinition } from "@arnilo/prism";
1
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
2
2
  /**
3
3
  * Pluggable operations for the write tool. Override to delegate file writing to remote systems
4
4
  * (e.g. SSH) while keeping the tool's directory-creation + per-path serialization behavior.
@@ -10,6 +10,8 @@ export interface WriteOperations {
10
10
  mkdir: (dir: string) => Promise<void>;
11
11
  }
12
12
  export interface WriteToolOptions {
13
+ /** Structured pre-execution policy checked before filesystem writes. */
14
+ executionPolicy?: ExecutionPolicy;
13
15
  /** Custom operations backend (default: local filesystem). */
14
16
  operations?: WriteOperations;
15
17
  }
package/dist/write.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import { Buffer } from "node:buffer";
18
18
  import { mkdir as fsMkdir, writeFile as fsWriteFile } from "node:fs/promises";
19
19
  import { dirname } from "node:path";
20
+ import { enforceExecutionPolicy } from "./execution-policy.js";
20
21
  import { resolveToCwd } from "./path-utils.js";
21
22
  import { withFileMutationQueue } from "./file-mutation-queue.js";
22
23
  const defaultWriteOperations = {
@@ -62,8 +63,18 @@ export function createWriteTool(cwd, options) {
62
63
  }
63
64
  try {
64
65
  const absolutePath = resolveToCwd(path, cwd);
65
- const dir = dirname(absolutePath);
66
- return await withFileMutationQueue(absolutePath, async () => {
66
+ const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
67
+ kind: "write",
68
+ operation: "write",
69
+ paths: [absolutePath],
70
+ risk: "medium",
71
+ metadata: { bytes: Buffer.byteLength(content, "utf-8"), signal: context.signal },
72
+ }, toolCallId, "write");
73
+ if (!policyCheck.allowed)
74
+ return policyCheck.result;
75
+ const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
76
+ const dir = dirname(allowedPath);
77
+ return await withFileMutationQueue(allowedPath, async () => {
67
78
  // Check abort before each fs op — do not start a new operation once aborted. We intentionally
68
79
  // do NOT throw from an abort listener: that could release the mutation queue mid-operation.
69
80
  if (context.signal?.aborted)
@@ -71,7 +82,7 @@ export function createWriteTool(cwd, options) {
71
82
  await ops.mkdir(dir);
72
83
  if (context.signal?.aborted)
73
84
  return errorResult(toolCallId, "Operation aborted");
74
- await ops.writeFile(absolutePath, content);
85
+ await ops.writeFile(allowedPath, content);
75
86
  const bytes = Buffer.byteLength(content, "utf-8");
76
87
  const lines = countLines(content);
77
88
  return {
@@ -80,10 +91,10 @@ export function createWriteTool(cwd, options) {
80
91
  content: [
81
92
  {
82
93
  type: "text",
83
- text: `Successfully wrote ${bytes} bytes (${lines} lines) to ${absolutePath}`,
94
+ text: `Successfully wrote ${bytes} bytes (${lines} lines) to ${allowedPath}`,
84
95
  },
85
96
  ],
86
- metadata: { bytes, lines, path: absolutePath },
97
+ metadata: { bytes, lines, path: allowedPath },
87
98
  };
88
99
  });
89
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-coding-agent",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Optional coding-agent tools (shell, read, write, edit) package for Prism.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "diff": "^8.0.4"
29
29
  },
30
30
  "peerDependencies": {
31
- "@arnilo/prism": "0.0.3"
31
+ "@arnilo/prism": "0.0.4"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@arnilo/prism": "file:../.."