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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmnav",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "A deterministic semantic navigation layer for LLM coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
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 };
@@ -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) {
@@ -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.0";
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"]);
@@ -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
- await mkdir(path.dirname(filePath), { recursive: true });
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);