@arnilo/prism-coding-agent 0.0.16 → 0.0.18

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
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.18] - 2026-07-30
4
+
5
+ ### Changed
6
+ - `repo_search` is literal-only: `mode: "regex"` removed from the tool schema; `compileSearchPattern` no longer compiles `RegExp` (ReDoS mitigation).
7
+ - Default `write`/`edit` local `writeFile` uses same-directory temp + `rename` for crash-safe replacement.
8
+
9
+ ## [0.0.17] - 2026-07-29
10
+
11
+ ### Added
12
+ - `ShellToolOptions.envAllowlist` restricts the environment the spawn hook and child process see (secret scrubbing without re-implementing the hook).
13
+
14
+ ### Changed
15
+ - Released with exact 0.0.17 graph.
16
+
3
17
  ## [0.0.16] - 2026-07-26
4
18
 
5
19
  ### Changed
package/README.md CHANGED
@@ -73,7 +73,7 @@ const askUser = createAskUserDecisionTool({
73
73
  | `write` | `{ path, content }` | Bounded UTF-8 input; `Successfully wrote N bytes (M lines) to <abs>`. |
74
74
  | `edit` | `{ path, edits: [{oldText,newText}] }` | Bounded target/input/count; `Successfully replaced N block(s)` + diff metadata. |
75
75
  | `repo_list` | `{ path?, includeHidden?, maxDepth?, maxResults?, offset? }` | Deterministic relative entries; skips hidden/excluded basenames; does not follow symlinks; paginates with `nextOffset`. |
76
- | `repo_search` | `{ query, path?, mode?, caseSensitive?, includeHidden?, context?, maxMatches? }` | Literal (default) or bounded regex matches with context; skips binary/excluded paths; finite scan/match/time caps. |
76
+ | `repo_search` | `{ query, path?, mode?, caseSensitive?, includeHidden?, context?, maxMatches? }` | Literal substring matches with context; skips binary/excluded paths; finite scan/match/time caps. |
77
77
  | `git_*` / `coding_check` | via `createGitTools(cwd, { commitIdentity, checks? })` | Opt-in structured Git status/diff/branch/worktree/apply/commit/PR-handoff and named checks. Not in `createCodingTools()`. |
78
78
  | `ask_user_decision` | via `createAskUserDecisionTool({ ask })` | Opt-in user choice: question + options (3 pros/3 cons); `selectionMode` single\|multiple; `allowCustom` for XOR free-text; host `ask` returns `selectedId` / `selectedIds` / `customText`. Durable: `suspendAskUserDecision` + resume validators. Not in default aggregators. |
79
79
 
@@ -0,0 +1,3 @@
1
+ export declare function atomicWriteUtf8File(targetPath: string, content: string, options?: {
2
+ signal?: AbortSignal;
3
+ }): Promise<void>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Same-directory temp + rename for crash-safe UTF-8 file replacement.
3
+ * Custom WriteOperations/EditOperations should provide equivalent durability.
4
+ */
5
+ import { randomBytes } from "node:crypto";
6
+ import { rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ export async function atomicWriteUtf8File(targetPath, content, options) {
9
+ const dir = dirname(targetPath);
10
+ const tempPath = join(dir, `.prism-write-${randomBytes(8).toString("hex")}`);
11
+ try {
12
+ await fsWriteFile(tempPath, content, { encoding: "utf-8", signal: options?.signal });
13
+ if (options?.signal?.aborted) {
14
+ await fsUnlink(tempPath).catch(() => { });
15
+ throw new Error("Operation aborted");
16
+ }
17
+ await fsRename(tempPath, targetPath);
18
+ }
19
+ catch (error) {
20
+ await fsUnlink(tempPath).catch(() => { });
21
+ throw error;
22
+ }
23
+ }
24
+ //# sourceMappingURL=atomic-write.js.map
package/dist/edit.js CHANGED
@@ -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, stat as fsStat, writeFile as fsWriteFile } from "node:fs/promises";
24
+ import { access as fsAccess, stat as fsStat } from "node:fs/promises";
25
+ import { atomicWriteUtf8File } from "./atomic-write.js";
25
26
  import { readFileBounded } from "./bounded-file.js";
26
27
  import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
27
28
  import { enforceExecutionPolicy } from "./execution-policy.js";
@@ -30,7 +31,7 @@ import { DEFAULT_MAX_EDIT_FILE_BYTES, DEFAULT_MAX_EDIT_INPUT_BYTES, DEFAULT_MAX_
30
31
  import { resolveToCwd } from "./path-utils.js";
31
32
  const defaultEditOperations = {
32
33
  readFile: (path, options) => readFileBounded(path, options.maxBytes, options.signal),
33
- writeFile: (path, content, options) => fsWriteFile(path, content, { encoding: "utf-8", signal: options?.signal }),
34
+ writeFile: (path, content, options) => atomicWriteUtf8File(path, content, { signal: options?.signal }),
34
35
  access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
35
36
  statFile: async (path) => ({ size: (await fsStat(path)).size }),
36
37
  };
@@ -85,7 +85,7 @@ export interface RepositorySearchRequest {
85
85
  readonly root: string;
86
86
  readonly query: string;
87
87
  readonly path?: string;
88
- readonly mode?: "literal" | "regex";
88
+ readonly mode?: "literal";
89
89
  readonly caseSensitive?: boolean;
90
90
  readonly includeHidden?: boolean;
91
91
  readonly exclude?: readonly string[];
@@ -116,7 +116,7 @@ export declare function resolveRepoPath(root: string, inputPath: string | undefi
116
116
  rootReal: string;
117
117
  }>;
118
118
  export declare function isBinaryBuffer(buffer: Buffer): boolean;
119
- export declare function compileSearchPattern(query: string, mode: "literal" | "regex", caseSensitive: boolean, maxPatternBytes: number): {
119
+ export declare function compileSearchPattern(query: string, caseSensitive: boolean, maxPatternBytes: number): {
120
120
  testLine: (line: string) => {
121
121
  column: number;
122
122
  } | null;
@@ -119,46 +119,28 @@ export function isBinaryBuffer(buffer) {
119
119
  }
120
120
  return false;
121
121
  }
122
- export function compileSearchPattern(query, mode, caseSensitive, maxPatternBytes) {
122
+ export function compileSearchPattern(query, caseSensitive, maxPatternBytes) {
123
123
  const patternBytes = Buffer.byteLength(query, "utf8");
124
124
  if (patternBytes < 1)
125
125
  throw new RepositoryError("query must be non-empty");
126
126
  if (patternBytes > maxPatternBytes) {
127
127
  throw new RepositoryError(`query exceeds ${maxPatternBytes} byte pattern limit`);
128
128
  }
129
- if (mode === "literal") {
130
- if (caseSensitive) {
131
- return {
132
- patternBytes,
133
- testLine: (line) => {
134
- const column = line.indexOf(query);
135
- return column >= 0 ? { column: column + 1 } : null;
136
- },
137
- };
138
- }
139
- const needle = query.toLowerCase();
129
+ if (caseSensitive) {
140
130
  return {
141
131
  patternBytes,
142
132
  testLine: (line) => {
143
- const column = line.toLowerCase().indexOf(needle);
133
+ const column = line.indexOf(query);
144
134
  return column >= 0 ? { column: column + 1 } : null;
145
135
  },
146
136
  };
147
137
  }
148
- let regex;
149
- try {
150
- regex = new RegExp(query, caseSensitive ? "u" : "iu");
151
- }
152
- catch (error) {
153
- const message = error instanceof Error ? error.message : String(error);
154
- throw new RepositoryError(`invalid regular expression: ${message}`);
155
- }
138
+ const needle = query.toLowerCase();
156
139
  return {
157
140
  patternBytes,
158
141
  testLine: (line) => {
159
- regex.lastIndex = 0;
160
- const match = regex.exec(line);
161
- return match && match.index !== undefined ? { column: match.index + 1 } : null;
142
+ const column = line.toLowerCase().indexOf(needle);
143
+ return column >= 0 ? { column: column + 1 } : null;
162
144
  },
163
145
  };
164
146
  }
@@ -477,11 +459,11 @@ async function searchFileLines(absolutePath, relativePath, testLine, options) {
477
459
  }
478
460
  async function searchLocal(request, defaults) {
479
461
  const mode = request.mode ?? "literal";
480
- if (mode !== "literal" && mode !== "regex") {
481
- throw new RepositoryError(`unsupported search mode: ${String(mode)}`);
462
+ if (mode !== "literal") {
463
+ throw new RepositoryError(`unsupported search mode: ${String(mode)} (literal only)`);
482
464
  }
483
465
  const caseSensitive = request.caseSensitive === true;
484
- const { testLine } = compileSearchPattern(request.query, mode, caseSensitive, defaults.maxPatternBytes);
466
+ const { testLine } = compileSearchPattern(request.query, caseSensitive, defaults.maxPatternBytes);
485
467
  const resolved = await resolveRepoPath(request.root, request.path);
486
468
  const maxMatches = validateCodingLimit("maxMatches", request.maxMatches ?? defaults.maxMatches, HARD_MAX_SEARCH_MATCHES);
487
469
  const context = validateCodingLimitAllowZero("context", request.context ?? defaults.maxContextLines, HARD_MAX_SEARCH_CONTEXT_LINES);
package/dist/search.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `repo_search` tool: bounded native literal/regex repository text search.
2
+ * `repo_search` tool: bounded native literal repository text search.
3
3
  */
4
4
  import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
5
5
  import { type RepositoryLimitOptions, type RepositoryOperations } from "./repository.js";
package/dist/search.js CHANGED
@@ -43,19 +43,19 @@ export function createRepoSearchTool(cwd, options) {
43
43
  const ops = options?.operations ?? createLocalRepositoryOperations(limits);
44
44
  return {
45
45
  name: "repo_search",
46
- description: `Search text files under the workspace. Default mode is literal substring match; set mode=regex for bounded regular expressions. Skips binary files, excluded basenames (default: ${limits.exclude.join(", ")}), and hidden names unless includeHidden is true. Does not follow symlinks. Caps matches/scanned bytes/time.`,
46
+ description: `Search text files under the workspace using literal substring match. Skips binary files, excluded basenames (default: ${limits.exclude.join(", ")}), and hidden names unless includeHidden is true. Does not follow symlinks. Caps matches/scanned bytes/time.`,
47
47
  parameters: {
48
48
  type: "object",
49
49
  properties: {
50
- query: { type: "string", description: "Literal text or regular expression to search for (required)" },
50
+ query: { type: "string", description: "Literal text to search for (required)" },
51
51
  path: {
52
52
  type: "string",
53
53
  description: "Workspace-relative directory or file to search (default: workspace root)",
54
54
  },
55
55
  mode: {
56
56
  type: "string",
57
- description: 'Search mode: "literal" (default) or "regex"',
58
- enum: ["literal", "regex"],
57
+ description: "Search mode: literal substring match only",
58
+ enum: ["literal"],
59
59
  },
60
60
  caseSensitive: {
61
61
  type: "boolean",
@@ -85,7 +85,13 @@ export function createRepoSearchTool(cwd, options) {
85
85
  if (query.length === 0)
86
86
  return errorResult(toolCallId, "query is required and must be a non-empty string.");
87
87
  const path = typeof args.path === "string" ? args.path : undefined;
88
- const mode = args.mode === "regex" ? "regex" : "literal";
88
+ if (args.mode === "regex") {
89
+ return errorResult(toolCallId, 'repo_search no longer supports mode "regex"; use literal substring search.');
90
+ }
91
+ if (args.mode !== undefined && args.mode !== "literal") {
92
+ return errorResult(toolCallId, `unsupported search mode: ${String(args.mode)}`);
93
+ }
94
+ const mode = "literal";
89
95
  const caseSensitive = args.caseSensitive === true;
90
96
  const includeHidden = args.includeHidden === true;
91
97
  let contextLines;
package/dist/shell.d.ts CHANGED
@@ -55,6 +55,9 @@ export interface ShellToolOptions {
55
55
  shellPath?: string;
56
56
  /** Hook to adjust command, cwd, or env before execution. */
57
57
  spawnHook?: BashSpawnHook;
58
+ /** Restrict the process environment cloned for the spawn hook / child process to these
59
+ * names (e.g. scrub secrets). Unset keeps the full `process.env` clone. */
60
+ envAllowlist?: readonly string[];
58
61
  /** Max lines kept in the tail snapshot (default 2000). */
59
62
  maxLines?: number;
60
63
  /** Max bytes kept in the tail snapshot (default 50KB). */
package/dist/shell.js CHANGED
@@ -29,6 +29,18 @@ import { OutputAccumulator } from "./output-accumulator.js";
29
29
  import { formatSize } from "./truncate.js";
30
30
  const EXIT_STDIO_GRACE_MS = 100;
31
31
  // --- spawn internals (re-ported from pi utils/shell.js + utils/child-process.js) ---
32
+ /** Clone the process environment, optionally restricted to an allowlist of names. */
33
+ function pickSpawnEnv(allowlist) {
34
+ if (!allowlist)
35
+ return { ...process.env };
36
+ const env = {};
37
+ for (const name of allowlist) {
38
+ const value = process.env[name];
39
+ if (value !== undefined)
40
+ env[name] = value;
41
+ }
42
+ return env;
43
+ }
32
44
  /** Resolve the shell binary + args. shellPath → SHELL env → /bin/bash → sh. */
33
45
  export function getShellConfig(customShellPath) {
34
46
  if (customShellPath) {
@@ -284,9 +296,10 @@ export function createShellTool(cwd, options) {
284
296
  return { toolCallId, name: "shell", content: [{ type: "text", text: message }], error: { message } };
285
297
  }
286
298
  const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
299
+ const baseEnv = pickSpawnEnv(options?.envAllowlist);
287
300
  let spawnContext = spawnHook
288
- ? spawnHook({ command: resolvedCommand, cwd, env: { ...process.env } })
289
- : { command: resolvedCommand, cwd, env: { ...process.env } };
301
+ ? spawnHook({ command: resolvedCommand, cwd, env: baseEnv })
302
+ : { command: resolvedCommand, cwd, env: baseEnv };
290
303
  const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
291
304
  kind: "shell",
292
305
  operation: "execute",
package/dist/write.js CHANGED
@@ -15,14 +15,15 @@
15
15
  * (pi would throw "Operation aborted" even after a successful write — misleading, so dropped).
16
16
  */
17
17
  import { Buffer } from "node:buffer";
18
- import { mkdir as fsMkdir, writeFile as fsWriteFile } from "node:fs/promises";
18
+ import { mkdir as fsMkdir } from "node:fs/promises";
19
19
  import { dirname } from "node:path";
20
+ import { atomicWriteUtf8File } from "./atomic-write.js";
20
21
  import { enforceExecutionPolicy } from "./execution-policy.js";
21
22
  import { withFileMutationQueue } from "./file-mutation-queue.js";
22
23
  import { DEFAULT_MAX_WRITE_BYTES, HARD_MAX_WRITE_BYTES, validateCodingLimit } from "./limits.js";
23
24
  import { resolveToCwd } from "./path-utils.js";
24
25
  const defaultWriteOperations = {
25
- writeFile: (path, content, options) => fsWriteFile(path, content, { encoding: "utf-8", signal: options?.signal }),
26
+ writeFile: (path, content, options) => atomicWriteUtf8File(path, content, { signal: options?.signal }),
26
27
  mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => { }),
27
28
  };
28
29
  function errorResult(toolCallId, message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-coding-agent",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Optional coding-agent tools (shell, read, write, edit, repo_list, repo_search, opt-in Git/check/ask-user-decision, and durable plan/checkpoint helpers) package for Prism.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,8 +28,8 @@
28
28
  "diff": "^9.0.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@arnilo/prism": "0.0.16",
32
- "@arnilo/prism-workflows": "0.0.16"
31
+ "@arnilo/prism": "0.0.18",
32
+ "@arnilo/prism-workflows": "0.0.18"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@arnilo/prism": "file:../..",