llmnav 0.7.0 → 0.7.1
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 +6 -0
- package/package.json +1 -1
- package/src/agents.js +7 -7
- package/src/cli.js +1 -1
- package/src/formatter.js +1 -1
- package/src/incremental.js +1 -1
- package/src/initializer.js +2 -2
- package/src/registry.js +1 -1
- package/src/spec.js +1 -1
- package/src/transaction.js +4 -4
- package/src/util.js +28 -5
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,12 @@ The npm package follows Semantic Versioning. The `llmnav/N` source protocol is v
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.1] — 2026-08-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
* Revalidated managed-write parent identity and symbolic-link traversal immediately before atomic replacement, preventing a concurrent directory swap from redirecting source formatting or control-file writes outside the repository.
|
|
14
|
+
|
|
9
15
|
## [0.7.0] — 2026-08-13
|
|
10
16
|
|
|
11
17
|
### Added
|
package/package.json
CHANGED
package/src/agents.js
CHANGED
|
@@ -42,19 +42,19 @@ export async function installAgentInstructions(root, adapters = ["agents"]) {
|
|
|
42
42
|
if (adapter === "agents") {
|
|
43
43
|
const target = path.join(root, "AGENTS.md");
|
|
44
44
|
await assertNoSymlinkTraversal(root, target, "AGENTS.md");
|
|
45
|
-
if (await upsertMarkdown(target, "# Repository instructions", AGENT_PROTOCOL)) {
|
|
45
|
+
if (await upsertMarkdown(root, target, "# Repository instructions", AGENT_PROTOCOL)) {
|
|
46
46
|
changed.push("AGENTS.md");
|
|
47
47
|
}
|
|
48
48
|
} else if (adapter === "claude") {
|
|
49
49
|
const target = path.join(root, "CLAUDE.md");
|
|
50
50
|
await assertNoSymlinkTraversal(root, target, "CLAUDE.md");
|
|
51
|
-
if (await upsertMarkdown(target, "# Claude Code instructions", AGENT_PROTOCOL)) {
|
|
51
|
+
if (await upsertMarkdown(root, target, "# Claude Code instructions", AGENT_PROTOCOL)) {
|
|
52
52
|
changed.push("CLAUDE.md");
|
|
53
53
|
}
|
|
54
54
|
} else if (adapter === "copilot") {
|
|
55
55
|
const target = path.join(root, ".github", "copilot-instructions.md");
|
|
56
56
|
await assertNoSymlinkTraversal(root, target, ".github/copilot-instructions.md");
|
|
57
|
-
if (await upsertMarkdown(target, "# GitHub Copilot instructions", AGENT_PROTOCOL)) {
|
|
57
|
+
if (await upsertMarkdown(root, target, "# GitHub Copilot instructions", AGENT_PROTOCOL)) {
|
|
58
58
|
changed.push(".github/copilot-instructions.md");
|
|
59
59
|
}
|
|
60
60
|
} else if (adapter === "cursor") {
|
|
@@ -63,7 +63,7 @@ export async function installAgentInstructions(root, adapters = ["agents"]) {
|
|
|
63
63
|
const content = `---\ndescription: Use LLMNav before broad codebase exploration\nalwaysApply: true\n---\n\n${AGENT_PROTOCOL}\n`;
|
|
64
64
|
const existing = await readText(target, "");
|
|
65
65
|
if (existing !== content) {
|
|
66
|
-
await atomicWrite(target, content);
|
|
66
|
+
await atomicWrite(root, target, content);
|
|
67
67
|
changed.push(".cursor/rules/llmnav.mdc");
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -73,7 +73,7 @@ export async function installAgentInstructions(root, adapters = ["agents"]) {
|
|
|
73
73
|
await assertNoSymlinkTraversal(root, canonicalPath, ".llmnav/AGENT_INSTRUCTIONS.md");
|
|
74
74
|
const canonical = `# LLMNav agent protocol\n\n${AGENT_PROTOCOL}\n`;
|
|
75
75
|
if ((await readText(canonicalPath, "")) !== canonical) {
|
|
76
|
-
await atomicWrite(canonicalPath, canonical);
|
|
76
|
+
await atomicWrite(root, canonicalPath, canonical);
|
|
77
77
|
changed.push(".llmnav/AGENT_INSTRUCTIONS.md");
|
|
78
78
|
}
|
|
79
79
|
return changed;
|
|
@@ -102,7 +102,7 @@ function adapterUsageError(message) {
|
|
|
102
102
|
return error;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
async function upsertMarkdown(filePath, title, block) {
|
|
105
|
+
async function upsertMarkdown(root, filePath, title, block) {
|
|
106
106
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
107
107
|
const existing = await readText(filePath, "");
|
|
108
108
|
let next;
|
|
@@ -124,6 +124,6 @@ async function upsertMarkdown(filePath, title, block) {
|
|
|
124
124
|
next = `${existing.trimEnd()}\n\n${block}\n`;
|
|
125
125
|
}
|
|
126
126
|
if (next === existing) return false;
|
|
127
|
-
await atomicWrite(filePath, next);
|
|
127
|
+
await atomicWrite(root, filePath, next);
|
|
128
128
|
return true;
|
|
129
129
|
}
|
package/src/cli.js
CHANGED
|
@@ -339,7 +339,7 @@ async function runAudit(root, args, json) {
|
|
|
339
339
|
? { schemaVersion: result.schemaVersion, repositoryId: result.repositoryId, summary: result.summary, failOn }
|
|
340
340
|
: report;
|
|
341
341
|
if (outputPath) {
|
|
342
|
-
await atomicWrite(outputPath, `${JSON.stringify(selectedReport, null, 2)}\n`);
|
|
342
|
+
await atomicWrite(root, outputPath, `${JSON.stringify(selectedReport, null, 2)}\n`);
|
|
343
343
|
}
|
|
344
344
|
if (json) {
|
|
345
345
|
console.log(JSON.stringify(outputPath
|
package/src/formatter.js
CHANGED
|
@@ -27,7 +27,7 @@ export async function formatProject(root, options = {}) {
|
|
|
27
27
|
errors.push(...result.errors.map((error) => ({ file: relativePath, ...error })));
|
|
28
28
|
if (!result.changed) continue;
|
|
29
29
|
changedFiles.push(relativePath);
|
|
30
|
-
if (!options.check) await atomicWrite(filePath, result.source);
|
|
30
|
+
if (!options.check) await atomicWrite(root, filePath, result.source, options.atomicWriteOptions);
|
|
31
31
|
}
|
|
32
32
|
const ok = errors.length === 0 && (options.check ? changedFiles.length === 0 : true);
|
|
33
33
|
return { ok, changedFiles, errors };
|
package/src/incremental.js
CHANGED
|
@@ -165,7 +165,7 @@ export function buildFileStateFromProject(project) {
|
|
|
165
165
|
|
|
166
166
|
export async function persistStatHints(root, hintsPath, statHints) {
|
|
167
167
|
await assertNoSymlinkTraversal(root, hintsPath, relativePosix(root, hintsPath));
|
|
168
|
-
await atomicWrite(hintsPath, stableStringify(statHints));
|
|
168
|
+
await atomicWrite(root, hintsPath, stableStringify(statHints));
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
export function renderFileState(fileState) {
|
package/src/initializer.js
CHANGED
|
@@ -123,7 +123,7 @@ async function writeIfMissingOrForced(root, filePath, content, force, changed, d
|
|
|
123
123
|
const existing = await readText(filePath, null);
|
|
124
124
|
if (existing !== null && !force) return;
|
|
125
125
|
if (existing === content) return;
|
|
126
|
-
await atomicWrite(filePath, content);
|
|
126
|
+
await atomicWrite(root, filePath, content);
|
|
127
127
|
changed.push(displayPath);
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -147,6 +147,6 @@ async function addPackageScripts(root) {
|
|
|
147
147
|
parsed.scripts[name] = command;
|
|
148
148
|
changed = true;
|
|
149
149
|
}
|
|
150
|
-
if (changed) await atomicWrite(packagePath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
150
|
+
if (changed) await atomicWrite(root, packagePath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
151
151
|
return changed;
|
|
152
152
|
}
|
package/src/registry.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function ensureActiveIds(root, registry, ids) {
|
|
|
79
79
|
}
|
|
80
80
|
await assertNoSymlinkTraversal(root, registryPath, ".llmnav/ids.jsonl");
|
|
81
81
|
const { records, changed } = mergeActiveIds(registry, ids);
|
|
82
|
-
if (changed) await atomicWrite(registryPath, renderRegistryRecords(records));
|
|
82
|
+
if (changed) await atomicWrite(root, registryPath, renderRegistryRecords(records));
|
|
83
83
|
return { records, changed };
|
|
84
84
|
}
|
|
85
85
|
|
package/src/spec.js
CHANGED
|
@@ -10,7 +10,7 @@ rel=workflow>llmnav.rules.validate
|
|
|
10
10
|
stability=contract
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export const PACKAGE_VERSION = "0.7.
|
|
13
|
+
export const PACKAGE_VERSION = "0.7.1";
|
|
14
14
|
export const SPEC_VERSION = "1";
|
|
15
15
|
|
|
16
16
|
export const SCOPES = Object.freeze(["file", "module", "symbol"]);
|
package/src/transaction.js
CHANGED
|
@@ -152,7 +152,7 @@ export async function commitGeneratedCache(root, cacheDirectory, artifacts, opti
|
|
|
152
152
|
controlArtifacts: controlRecords,
|
|
153
153
|
phase: "prepared",
|
|
154
154
|
};
|
|
155
|
-
await atomicWrite(journalPath, stableStringify(journal));
|
|
155
|
+
await atomicWrite(root, journalPath, stableStringify(journal));
|
|
156
156
|
await invokeFailpoint("after-journal", options);
|
|
157
157
|
|
|
158
158
|
if (hadExistingCache) {
|
|
@@ -160,19 +160,19 @@ export async function commitGeneratedCache(root, cacheDirectory, artifacts, opti
|
|
|
160
160
|
}
|
|
161
161
|
await moveControlArtifactsToBackup(root, controlRecords, options.renameOptions);
|
|
162
162
|
journal = { ...journal, phase: "old-moved" };
|
|
163
|
-
await atomicWrite(journalPath, stableStringify(journal));
|
|
163
|
+
await atomicWrite(root, journalPath, stableStringify(journal));
|
|
164
164
|
await invokeFailpoint("after-cache-moved", options);
|
|
165
165
|
|
|
166
166
|
await renameWithRetry(stagePath, cachePath, options.renameOptions);
|
|
167
167
|
await installControlArtifacts(root, controlRecords, options.renameOptions);
|
|
168
168
|
journal = { ...journal, phase: "new-installed" };
|
|
169
|
-
await atomicWrite(journalPath, stableStringify(journal));
|
|
169
|
+
await atomicWrite(root, journalPath, stableStringify(journal));
|
|
170
170
|
await verifyCommittedCache(cachePath, cacheRelative);
|
|
171
171
|
await verifyControlArtifacts(root, controlRecords);
|
|
172
172
|
await invokeFailpoint("after-new-installed", options);
|
|
173
173
|
|
|
174
174
|
journal = { ...journal, phase: "committed" };
|
|
175
|
-
await atomicWrite(journalPath, stableStringify(journal));
|
|
175
|
+
await atomicWrite(root, journalPath, stableStringify(journal));
|
|
176
176
|
if (hadExistingCache) await removeWithRetry(backupPath, options.renameOptions);
|
|
177
177
|
await removeWithRetry(transactionPath, options.renameOptions);
|
|
178
178
|
await removeWithRetry(journalPath, options.renameOptions);
|
package/src/util.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
export function normalizeNewlines(value) {
|
|
@@ -137,18 +137,41 @@ export async function readJsonSafe(filePath, fallback = null) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
export async function atomicWrite(filePath, content) {
|
|
141
|
-
|
|
140
|
+
export async function atomicWrite(root, filePath, content, options = {}) {
|
|
141
|
+
const parent = path.dirname(filePath);
|
|
142
|
+
const label = options.label ?? relativePosix(root, filePath);
|
|
143
|
+
await assertNoSymlinkTraversal(root, filePath, label);
|
|
144
|
+
await mkdir(parent, { recursive: true });
|
|
145
|
+
const parentIdentity = await directoryIdentity(root, parent, label);
|
|
142
146
|
const temporaryPath = `${filePath}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
143
|
-
await writeFile(temporaryPath, content, "utf8");
|
|
147
|
+
await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx" });
|
|
144
148
|
try {
|
|
149
|
+
await options.beforeCommit?.({ filePath, temporaryPath });
|
|
150
|
+
await assertNoSymlinkTraversal(root, filePath, label);
|
|
151
|
+
const currentIdentity = await directoryIdentity(root, parent, label);
|
|
152
|
+
if (currentIdentity !== parentIdentity) throw new Error(`${label} parent directory changed during atomic write.`);
|
|
145
153
|
await rename(temporaryPath, filePath);
|
|
146
154
|
} catch (error) {
|
|
147
|
-
await rm(temporaryPath, { force: true });
|
|
155
|
+
if (await parentHasIdentity(root, parent, parentIdentity)) await rm(temporaryPath, { force: true });
|
|
148
156
|
throw error;
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
159
|
|
|
160
|
+
async function directoryIdentity(root, directory, label) {
|
|
161
|
+
await assertNoSymlinkTraversal(root, directory, label);
|
|
162
|
+
const [details, canonical] = await Promise.all([lstat(directory), realpath(directory)]);
|
|
163
|
+
if (!details.isDirectory()) throw new Error(`${label} parent is not a directory.`);
|
|
164
|
+
return `${details.dev}:${details.ino}:${path.normalize(canonical)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function parentHasIdentity(root, parent, expected) {
|
|
168
|
+
try {
|
|
169
|
+
return (await directoryIdentity(root, parent, parent)) === expected;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
152
175
|
export async function assertNoSymlinkTraversal(root, targetPath, label = targetPath) {
|
|
153
176
|
const rootPath = path.resolve(root);
|
|
154
177
|
const target = path.resolve(targetPath);
|