|
| 1 | +import { realpathSync } from "node:fs"; |
1 | 2 | import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; |
2 | 3 | import { homedir } from "node:os"; |
3 | 4 | import { dirname, isAbsolute, join, resolve } from "node:path"; |
@@ -64,14 +65,29 @@ function extractStringArrayField(value: unknown[] | undefined, field: string, pa |
64 | 65 | return strings; |
65 | 66 | } |
66 | 67 |
|
| 68 | +// Symlink twins of the same repo (e.g. macOS's /tmp -> /private/tmp) must key |
| 69 | +// and compare as the same project — otherwise grants written via one spelling |
| 70 | +// are invisible via the other (fail-closed availability) and each spelling |
| 71 | +// accumulates its own duplicate store. realpath collapses the twins; a path |
| 72 | +// that doesn't exist yet (or isn't readable) falls back to the lexical |
| 73 | +// resolve so callers never see an error from this normalization step alone. |
| 74 | +function canonicalizeCwd(cwd: string): string { |
| 75 | + const resolved = resolve(cwd); |
| 76 | + try { |
| 77 | + return realpathSync(resolved); |
| 78 | + } catch { |
| 79 | + return resolved; |
| 80 | + } |
| 81 | +} |
| 82 | + |
67 | 83 | // SECURITY: project trust records must NOT live inside the repo they authorize — |
68 | 84 | // a hostile repo could otherwise ship its own `.corbits/trust.json` and |
69 | 85 | // pre-grant consent to its plugins and MCP servers. We store them under the |
70 | 86 | // user's home, in a file keyed by the resolved repo path, so only prior |
71 | 87 | // interactive consent on THIS machine can populate them. Path-origin plugins |
72 | 88 | // use a separate global store (`path-trust.ts`); do not OR the two lists. |
73 | 89 | export function projectTrustPath(cwd: string, home: string = homedir()): string { |
74 | | - const repo = resolve(cwd); |
| 90 | + const repo = canonicalizeCwd(cwd); |
75 | 91 | const key = createHash("sha256").update(repo).digest("hex").slice(0, 32); |
76 | 92 | return join(home, SETTINGS_DIR_NAME, "trust", `${key}.json`); |
77 | 93 | } |
@@ -138,8 +154,8 @@ export async function readProjectTrustStore( |
138 | 154 | logger.warn`project trust store missing repo field at ${path}`; |
139 | 155 | return { state: "invalid", store: emptyStore() }; |
140 | 156 | } |
141 | | - if (resolve(validated.repo) !== resolve(cwd)) { |
142 | | - logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${resolve(cwd)}`; |
| 157 | + if (canonicalizeCwd(validated.repo) !== canonicalizeCwd(cwd)) { |
| 158 | + logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${canonicalizeCwd(cwd)}`; |
143 | 159 | return { state: "invalid", store: emptyStore() }; |
144 | 160 | } |
145 | 161 | // Grants are recorded as absolute paths (see requireAbsolute below); a |
@@ -173,7 +189,7 @@ export async function loadProjectTrust(cwd: string, home: string = homedir()): P |
173 | 189 | async function saveProjectTrust(cwd: string, store: ProjectTrustStore, home: string = homedir()): Promise<void> { |
174 | 190 | const path = projectTrustPath(cwd, home); |
175 | 191 | await mkdir(dirname(path), { recursive: true, mode: 0o700 }); |
176 | | - const record = { repo: resolve(cwd), ...store }; |
| 192 | + const record = { repo: canonicalizeCwd(cwd), ...store }; |
177 | 193 | const tmp = `${path}.${process.pid}.tmp`; |
178 | 194 | await writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); |
179 | 195 | await rename(tmp, path); |
@@ -205,7 +221,7 @@ function enqueueMutation<T>(key: string, run: () => Promise<T>): Promise<T> { |
205 | 221 | // project cwd instead of rejecting — path.resolve(cwd, pluginPath) leaves an |
206 | 222 | // already-absolute pluginPath untouched. |
207 | 223 | function resolveAgainstProjectCwd(cwd: string, pluginPath: string): string { |
208 | | - return resolve(cwd, pluginPath); |
| 224 | + return resolve(canonicalizeCwd(cwd), pluginPath); |
209 | 225 | } |
210 | 226 |
|
211 | 227 | export function isPluginTrusted(store: ProjectTrustStore, pluginPath: string, cwd: string = process.cwd()): boolean { |
|
0 commit comments