@wrongstack/tools 0.281.3 → 0.282.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.
- package/dist/audit.js +34 -13
- package/dist/audit.js.map +1 -1
- package/dist/bash.js +41 -23
- package/dist/bash.js.map +1 -1
- package/dist/builtin.js +613 -72
- package/dist/builtin.js.map +1 -1
- package/dist/codebase-index/index.js +61 -2
- package/dist/codebase-index/index.js.map +1 -1
- package/dist/codebase-index/worker.js +61 -2
- package/dist/codebase-index/worker.js.map +1 -1
- package/dist/exec.js +34 -13
- package/dist/exec.js.map +1 -1
- package/dist/format.js +34 -13
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +242 -192
- package/dist/index.js +12613 -12074
- package/dist/index.js.map +1 -1
- package/dist/install.js +34 -13
- package/dist/install.js.map +1 -1
- package/dist/lint.js +34 -13
- package/dist/lint.js.map +1 -1
- package/dist/pack.js +612 -72
- package/dist/pack.js.map +1 -1
- package/dist/process-registry.d.ts +16 -3
- package/dist/process-registry.js +34 -13
- package/dist/process-registry.js.map +1 -1
- package/dist/test.js +34 -13
- package/dist/test.js.map +1 -1
- package/dist/typecheck.js +34 -13
- package/dist/typecheck.js.map +1 -1
- package/dist/write.js +65 -46
- package/dist/write.js.map +1 -1
- package/package.json +2 -2
package/dist/write.js
CHANGED
|
@@ -82,56 +82,75 @@ var writeTool = {
|
|
|
82
82
|
required: ["path", "content"]
|
|
83
83
|
},
|
|
84
84
|
async execute(input, ctx) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
message: "write: content is required",
|
|
94
|
-
field: "content"
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
const absPath = await safeResolveReal(input.path, ctx);
|
|
98
|
-
let existed = false;
|
|
99
|
-
let prev = "";
|
|
100
|
-
try {
|
|
101
|
-
const stat3 = await fs.stat(absPath);
|
|
102
|
-
existed = stat3.isFile();
|
|
103
|
-
if (existed) {
|
|
104
|
-
if (!ctx.hasRead(absPath)) {
|
|
105
|
-
prev = await fs.readFile(absPath, "utf8");
|
|
106
|
-
ctx.recordRead(absPath, stat3.mtimeMs, "write");
|
|
107
|
-
} else {
|
|
108
|
-
prev = await fs.readFile(absPath, "utf8");
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
} catch (err) {
|
|
112
|
-
if (err.code !== "ENOENT") {
|
|
113
|
-
throw err;
|
|
85
|
+
return writeFile(input, ctx);
|
|
86
|
+
},
|
|
87
|
+
async *executeStream(input, ctx) {
|
|
88
|
+
const prepared = await prepareWrite(input, ctx);
|
|
89
|
+
if (!prepared.existed) {
|
|
90
|
+
for (const line of input.content.split("\n")) {
|
|
91
|
+
yield { type: "partial_output", text: `${line}
|
|
92
|
+
`, data: { livePreview: true } };
|
|
114
93
|
}
|
|
115
94
|
}
|
|
116
|
-
await
|
|
117
|
-
const diff = existed ? unifiedDiff(prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
|
|
118
|
-
+ (new file, ${input.content.split("\n").length} lines)`;
|
|
119
|
-
const stat2 = await fs.stat(absPath);
|
|
120
|
-
ctx.recordRead(absPath, stat2.mtimeMs, "write");
|
|
121
|
-
ctx.session.recordFileChange({
|
|
122
|
-
path: absPath,
|
|
123
|
-
action: existed ? "modified" : "created",
|
|
124
|
-
before: existed ? prev : null,
|
|
125
|
-
after: input.content
|
|
126
|
-
});
|
|
127
|
-
return {
|
|
128
|
-
path: absPath,
|
|
129
|
-
bytes_written: Buffer.byteLength(input.content, "utf8"),
|
|
130
|
-
created: !existed,
|
|
131
|
-
diff
|
|
132
|
-
};
|
|
95
|
+
yield { type: "final", output: await finishWrite(input, ctx, prepared) };
|
|
133
96
|
}
|
|
134
97
|
};
|
|
98
|
+
async function writeFile(input, ctx) {
|
|
99
|
+
return finishWrite(input, ctx, await prepareWrite(input, ctx));
|
|
100
|
+
}
|
|
101
|
+
async function prepareWrite(input, ctx) {
|
|
102
|
+
if (!input?.path) {
|
|
103
|
+
throw new ToolValidationError({
|
|
104
|
+
message: "write: path is required",
|
|
105
|
+
field: "path"
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (input.content === void 0) {
|
|
109
|
+
throw new ToolValidationError({
|
|
110
|
+
message: "write: content is required",
|
|
111
|
+
field: "content"
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const absPath = await safeResolveReal(input.path, ctx);
|
|
115
|
+
let existed = false;
|
|
116
|
+
let prev = "";
|
|
117
|
+
try {
|
|
118
|
+
const stat2 = await fs.stat(absPath);
|
|
119
|
+
existed = stat2.isFile();
|
|
120
|
+
if (existed) {
|
|
121
|
+
if (!ctx.hasRead(absPath)) {
|
|
122
|
+
prev = await fs.readFile(absPath, "utf8");
|
|
123
|
+
ctx.recordRead(absPath, stat2.mtimeMs, "write");
|
|
124
|
+
} else {
|
|
125
|
+
prev = await fs.readFile(absPath, "utf8");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err.code !== "ENOENT") {
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { absPath, existed, prev };
|
|
134
|
+
}
|
|
135
|
+
async function finishWrite(input, ctx, prepared) {
|
|
136
|
+
await atomicWrite(prepared.absPath, input.content);
|
|
137
|
+
const diff = prepared.existed ? unifiedDiff(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
|
|
138
|
+
+ (new file, ${input.content.split("\n").length} lines)`;
|
|
139
|
+
const stat2 = await fs.stat(prepared.absPath);
|
|
140
|
+
ctx.recordRead(prepared.absPath, stat2.mtimeMs, "write");
|
|
141
|
+
ctx.session.recordFileChange({
|
|
142
|
+
path: prepared.absPath,
|
|
143
|
+
action: prepared.existed ? "modified" : "created",
|
|
144
|
+
before: prepared.existed ? prepared.prev : null,
|
|
145
|
+
after: input.content
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
path: prepared.absPath,
|
|
149
|
+
bytes_written: Buffer.byteLength(input.content, "utf8"),
|
|
150
|
+
created: !prepared.existed,
|
|
151
|
+
diff
|
|
152
|
+
};
|
|
153
|
+
}
|
|
135
154
|
|
|
136
155
|
export { writeTool };
|
|
137
156
|
//# sourceMappingURL=write.js.map
|
package/dist/write.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/_util.ts","../src/write.ts"],"names":["fsp","stat"],"mappings":"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","file":"write.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport { atomicWrite, ToolValidationError, unifiedDiff } from '@wrongstack/core';\nimport type { Tool } from '@wrongstack/core';\nimport { safeResolveReal } from './_util.js';\n\ninterface WriteInput {\n path: string;\n content: string;\n}\n\ninterface WriteOutput {\n path: string;\n bytes_written: number;\n created: boolean;\n diff?: string | undefined;\n}\n\nexport const writeTool: Tool<WriteInput, WriteOutput> = {\n name: 'write',\n category: 'Filesystem',\n description:\n 'Write or completely overwrite a file on disk. ' +\n 'This is a high-privilege operation. For modifying existing files, you should almost always prefer the `edit` tool instead, ' +\n 'because `edit` is safer and works on the last-read version of the file.',\n usageHint:\n 'RULES FOR CORRECT USAGE:\\n' +\n '- Use `write` primarily for **new files** or when you want to replace the entire content.\\n' +\n '- For any existing file, strongly prefer `edit` (it requires a prior `read` in the same session and is more precise).\\n' +\n '- You MUST have called `read` on the file earlier in the conversation before using `write` on an existing path (the system enforces this for safety).\\n' +\n '- The path is resolved relative to the project root and protected against escaping the workspace.',\n permission: 'confirm',\n mutating: true,\n timeoutMs: 5_000,\n capabilities: ['fs.write'],\n icon: 'file',\n inputSchema: {\n type: 'object',\n properties: {\n path: {\n type: 'string',\n description: 'Relative path from project root. Must not escape the project.',\n },\n content: {\n type: 'string',\n description: 'The complete new content of the file.',\n },\n },\n required: ['path', 'content'],\n },\n async execute(input, ctx) {\n if (!input?.path) {\n throw new ToolValidationError({\n message: 'write: path is required',\n field: 'path',\n });\n }\n if (input.content === undefined) {\n throw new ToolValidationError({\n message: 'write: content is required',\n field: 'content',\n });\n }\n const absPath = await safeResolveReal(input.path, ctx);\n\n let existed = false;\n let prev = '';\n try {\n const stat = await fs.stat(absPath);\n existed = stat.isFile();\n if (existed) {\n if (!ctx.hasRead(absPath)) {\n // User approved this write (confirm → yes/always) but ctx has no\n // read record. The model may call write without a prior explicit\n // read. Read the file now so we can compute the diff and honor\n // the user's intent to overwrite. Tag as 'write' (NOT 'user') so\n // this internal read-for-diff does not widen the permission bypass\n // — the user never saw the old content (P1 #1).\n prev = await fs.readFile(absPath, 'utf8');\n ctx.recordRead(absPath, stat.mtimeMs, 'write');\n } else {\n prev = await fs.readFile(absPath, 'utf8');\n }\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {\n throw err;\n }\n }\n\n await atomicWrite(absPath, input.content);\n\n const diff = existed\n ? unifiedDiff(prev, input.content, { fromFile: input.path, toFile: input.path })\n : `+++ ${input.path}\\n+ (new file, ${input.content.split('\\n').length} lines)`;\n\n const stat = await fs.stat(absPath);\n // Tag as 'write' so the permission bypass does not auto-approve a later\n // write to this path — the user approved THIS write, not future ones\n // (P1 #1).\n ctx.recordRead(absPath, stat.mtimeMs, 'write');\n\n // Record for session rewind\n ctx.session.recordFileChange({\n path: absPath,\n action: existed ? 'modified' : 'created',\n before: existed ? prev : null,\n after: input.content,\n });\n\n return {\n path: absPath,\n bytes_written: Buffer.byteLength(input.content, 'utf8'),\n created: !existed,\n diff,\n };\n },\n};\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/_util.ts","../src/write.ts"],"names":["fsp","stat"],"mappings":"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","file":"write.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport { atomicWrite, ToolValidationError, unifiedDiff } from '@wrongstack/core';\nimport type { Context, Tool } from '@wrongstack/core';\nimport { safeResolveReal } from './_util.js';\n\ninterface WriteInput {\n path: string;\n content: string;\n}\n\ninterface WriteOutput {\n path: string;\n bytes_written: number;\n created: boolean;\n diff?: string | undefined;\n}\n\nexport const writeTool: Tool<WriteInput, WriteOutput> = {\n name: 'write',\n category: 'Filesystem',\n description:\n 'Write or completely overwrite a file on disk. ' +\n 'This is a high-privilege operation. For modifying existing files, you should almost always prefer the `edit` tool instead, ' +\n 'because `edit` is safer and works on the last-read version of the file.',\n usageHint:\n 'RULES FOR CORRECT USAGE:\\n' +\n '- Use `write` primarily for **new files** or when you want to replace the entire content.\\n' +\n '- For any existing file, strongly prefer `edit` (it requires a prior `read` in the same session and is more precise).\\n' +\n '- You MUST have called `read` on the file earlier in the conversation before using `write` on an existing path (the system enforces this for safety).\\n' +\n '- The path is resolved relative to the project root and protected against escaping the workspace.',\n permission: 'confirm',\n mutating: true,\n timeoutMs: 5_000,\n capabilities: ['fs.write'],\n icon: 'file',\n inputSchema: {\n type: 'object',\n properties: {\n path: {\n type: 'string',\n description: 'Relative path from project root. Must not escape the project.',\n },\n content: {\n type: 'string',\n description: 'The complete new content of the file.',\n },\n },\n required: ['path', 'content'],\n },\n async execute(input, ctx) {\n return writeFile(input, ctx);\n },\n async *executeStream(input, ctx) {\n const prepared = await prepareWrite(input, ctx);\n if (!prepared.existed) {\n for (const line of input.content.split('\\n')) {\n yield { type: 'partial_output', text: `${line}\\n`, data: { livePreview: true } };\n }\n }\n yield { type: 'final', output: await finishWrite(input, ctx, prepared) };\n },\n};\n\ntype PreparedWrite = {\n absPath: string;\n existed: boolean;\n prev: string;\n};\n\nasync function writeFile(input: WriteInput, ctx: Context): Promise<WriteOutput> {\n return finishWrite(input, ctx, await prepareWrite(input, ctx));\n}\n\nasync function prepareWrite(input: WriteInput, ctx: Context): Promise<PreparedWrite> {\n if (!input?.path) {\n throw new ToolValidationError({\n message: 'write: path is required',\n field: 'path',\n });\n }\n if (input.content === undefined) {\n throw new ToolValidationError({\n message: 'write: content is required',\n field: 'content',\n });\n }\n const absPath = await safeResolveReal(input.path, ctx);\n\n let existed = false;\n let prev = '';\n try {\n const stat = await fs.stat(absPath);\n existed = stat.isFile();\n if (existed) {\n if (!ctx.hasRead(absPath)) {\n // User approved this write (confirm → yes/always) but ctx has no\n // read record. The model may call write without a prior explicit\n // read. Read the file now so we can compute the diff and honor\n // the user's intent to overwrite. Tag as 'write' (NOT 'user') so\n // this internal read-for-diff does not widen the permission bypass\n // — the user never saw the old content (P1 #1).\n prev = await fs.readFile(absPath, 'utf8');\n ctx.recordRead(absPath, stat.mtimeMs, 'write');\n } else {\n prev = await fs.readFile(absPath, 'utf8');\n }\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {\n throw err;\n }\n }\n\n return { absPath, existed, prev };\n}\n\nasync function finishWrite(\n input: WriteInput,\n ctx: Context,\n prepared: PreparedWrite,\n): Promise<WriteOutput> {\n await atomicWrite(prepared.absPath, input.content);\n\n const diff = prepared.existed\n ? unifiedDiff(prepared.prev, input.content, { fromFile: input.path, toFile: input.path })\n : `+++ ${input.path}\\n+ (new file, ${input.content.split('\\n').length} lines)`;\n\n const stat = await fs.stat(prepared.absPath);\n // Tag as 'write' so the permission bypass does not auto-approve a later\n // write to this path — the user approved THIS write, not future ones\n // (P1 #1).\n ctx.recordRead(prepared.absPath, stat.mtimeMs, 'write');\n\n // Record for session rewind\n ctx.session.recordFileChange({\n path: prepared.absPath,\n action: prepared.existed ? 'modified' : 'created',\n before: prepared.existed ? prepared.prev : null,\n after: input.content,\n });\n\n return {\n path: prepared.absPath,\n bytes_written: Buffer.byteLength(input.content, 'utf8'),\n created: !prepared.existed,\n diff,\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wrongstack/tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.282.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "WrongStack built-in tools: read/write/edit, bash/exec, grep/glob, git, fetch, test, lint, and more.",
|
|
6
6
|
"repository": {
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
"turndown": "^7.2.4",
|
|
185
185
|
"typescript": "^6.0.3",
|
|
186
186
|
"undici": "^8.5.0",
|
|
187
|
-
"@wrongstack/core": "0.
|
|
187
|
+
"@wrongstack/core": "0.282.0"
|
|
188
188
|
},
|
|
189
189
|
"devDependencies": {
|
|
190
190
|
"@types/node": "^26.0.1",
|