fix(application): verify configuration and reference input reads

This commit is contained in:
morluto
2026-10-11 00:28:24 +08:00
parent abb8e81d3a
commit a9fd1aecae
7 changed files with 105 additions and 52 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import { err, ok, type Result } from "../domain/result.js";
import { readFile } from "node:fs/promises";
import { readRegularFileText } from "./RegularFileRead.js";
import {
effectiveClientServer,
parseClientConfiguration,
@@ -19,7 +19,7 @@ export const readConfigurationText = async (
path: string,
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
try {
return ok(await readFile(path, "utf8"));
return ok(await readRegularFileText(path));
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return ok(undefined);
+36 -9
View File
@@ -1,7 +1,8 @@
import { readFile, realpath, stat } from "node:fs/promises";
import { lstat, realpath, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import ignore from "ignore";
import { readRegularFileText } from "./RegularFileRead.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -47,14 +48,27 @@ const resolveRoot = async (
const buildIgnored = async (
root: string,
excludePaths: readonly string[],
signal?: AbortSignal,
): Promise<ReturnType<typeof ignore>> => {
const ignored = ignore();
const policyPath = join(root, ".gitignore");
let present = true;
try {
ignored.add(await readFile(join(root, ".gitignore"), "utf8"));
await lstat(policyPath);
} catch (cause: unknown) {
// Missing or unreadable ignore file does not authorize broader access.
void cause;
if (
!(
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
)
)
throw cause;
present = false;
}
signal?.throwIfAborted();
if (present) ignored.add(await readRegularFileText(policyPath, { signal }));
ignored.add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]);
for (const path of excludePaths) {
ignored.add(path);
@@ -71,9 +85,22 @@ export const prepareReferenceSourceImport = async (
> => {
const root = await resolveRoot(options.root);
if (!root.ok) return root;
return ok({
root: root.value,
ignored: await buildIgnored(root.value, options.excludePaths ?? []),
secrets: ignore().add([...options.policy.secretPatterns]),
});
try {
return ok({
root: root.value,
ignored: await buildIgnored(
root.value,
options.excludePaths ?? [],
options.signal,
),
secrets: ignore().add([...options.policy.secretPatterns]),
});
} catch (cause: unknown) {
return err(
failure(
options.signal?.aborted === true ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${cause instanceof Error ? cause.message : String(cause)}`,
),
);
}
};
+47 -17
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import { lstat, readFile } from "node:fs/promises";
import { lstat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { readRegularFile, readRegularFileText } from "./RegularFileRead.js";
export type ReferenceSourceVcsInfo =
| {
@@ -29,7 +30,7 @@ export const readReferenceSourceVcs = async (
: { kind: "unknown", head: null, dirty: null };
}
try {
const head = await resolveSourceHead(root);
const head = await resolveSourceHead(root, signal);
if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null };
return { kind: "git", head, dirty: null };
} catch (cause: unknown) {
@@ -39,13 +40,44 @@ export const readReferenceSourceVcs = async (
}
};
const resolveSourceHead = async (root: string): Promise<string> => {
const resolveSourceHead = async (
root: string,
signal?: AbortSignal,
): Promise<string> => {
// isomorphic-git is loaded on first use so CLI and MCP startup skip it.
const { resolveRef } = await import("isomorphic-git");
let failedRead: { readonly cause: unknown } | undefined;
const referenceFs = {
...fs,
promises: {
...fs.promises,
// isomorphic-git treats every read failure as absence. Preserve real
// failures so a readable packed ref cannot hide an unreadable loose ref.
readFile: async (path: string) => {
try {
return await readRegularFile(path, { signal });
} catch (cause: unknown) {
if (errorCode(cause) !== "ENOENT") failedRead ??= { cause };
throw cause;
}
},
},
};
const readRef = async (options: {
readonly gitdir?: string;
readonly ref: string;
}) => {
const head = await resolveRef({ fs: referenceFs, dir: root, ...options });
signal?.throwIfAborted();
if (failedRead !== undefined) throw failedRead.cause;
return head;
};
const dotgit = join(root, ".git");
if (!(await lstat(dotgit)).isFile())
return resolveRef({ fs, dir: root, ref: "HEAD" });
const pointer = (await readFile(dotgit, "utf8")).replace(/\r?\n$/u, "");
if (!(await lstat(dotgit)).isFile()) return readRef({ ref: "HEAD" });
const pointer = (await readRegularFileText(dotgit, { signal })).replace(
/\r?\n$/u,
"",
);
if (!pointer.startsWith("gitdir: ") || pointer.length === 8)
throw new Error("Invalid Git directory pointer");
const gitdir = resolve(root, pointer.slice(8));
@@ -53,25 +85,23 @@ const resolveSourceHead = async (root: string): Promise<string> => {
try {
commonDirectory = resolve(
gitdir,
(await readFile(join(gitdir, "commondir"), "utf8")).replace(
/\r?\n$/u,
"",
),
(
await readRegularFileText(join(gitdir, "commondir"), { signal })
).replace(/\r?\n$/u, ""),
);
} catch (cause: unknown) {
if (errorCode(cause) !== "ENOENT") throw cause;
return resolveRef({ fs, dir: root, gitdir, ref: "HEAD" });
return readRef({ gitdir, ref: "HEAD" });
}
const head = (await readFile(join(gitdir, "HEAD"), "utf8")).trim();
if (!head.startsWith("ref: "))
return resolveRef({ fs, dir: root, gitdir, ref: "HEAD" });
const head = (
await readRegularFileText(join(gitdir, "HEAD"), { signal })
).trim();
if (!head.startsWith("ref: ")) return readRef({ gitdir, ref: "HEAD" });
const ref = head.slice(5);
const privateRef = ["refs/bisect/", "refs/rewritten/", "refs/worktree/"].some(
(prefix) => ref.startsWith(prefix),
);
return resolveRef({
fs,
dir: root,
return readRef({
gitdir: privateRef ? gitdir : commonDirectory,
ref,
});
+6 -5
View File
@@ -14,7 +14,7 @@ import {
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { constants as fsConstants } from "node:fs";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import writeFileAtomic from "write-file-atomic";
@@ -22,6 +22,7 @@ import { PRODUCT_IDENTITY } from "../identity.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { readRegularFileText } from "./RegularFileRead.js";
import type {
ClientConfigurationInspection,
ClientConfigurationResult,
@@ -64,7 +65,7 @@ const configureClientDocument = async (
return { status: "failed", reason: "path" };
let original: string | undefined;
try {
original = await readFile(transactionPath, "utf8");
original = await readRegularFileText(transactionPath);
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
@@ -149,7 +150,7 @@ const configureClientDocument = async (
}
try {
const readback = parseClientConfiguration(
await readFile(transactionPath, "utf8"),
await readRegularFileText(transactionPath),
format,
);
if (!registrationCurrent(readback, desired)) {
@@ -178,7 +179,7 @@ export const clientConfigurationAligned = async (
try {
const files = await readClientConfigurationFiles(client);
if (!files.ok) return false;
const original = await readFile(client.configPath, "utf8");
const original = await readRegularFileText(client.configPath);
const parsed = parseClientConfiguration(original, client.format);
const policy = await readClientPolicyBlock(
client,
@@ -246,7 +247,7 @@ export const inspectClientConfiguration = async (
};
let original: string;
try {
original = await readFile(transactionPath, "utf8");
original = await readRegularFileText(transactionPath);
} catch (cause: unknown) {
if (isMissing(cause)) return { status: "create" };
return {
+6 -14
View File
@@ -4,10 +4,8 @@ import { dirname, join } from "node:path";
import writeFileAtomic from "write-file-atomic";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
NonRegularFileReadError,
openRegularFile,
} from "../filesystem/RegularFile.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
import { readRegularFileText } from "./RegularFileRead.js";
import { clientSkillDirectories } from "./SupportedClients.js";
export const MANAGED_SKILL_FILES = [
@@ -23,14 +21,8 @@ export const isManagedSkillManifest = (content: string): boolean =>
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
export const readSkillFile = async (path: string): Promise<string> => {
const handle = await openRegularFile(path, { symlinks: "reject" });
try {
return await handle.readFile("utf8");
} finally {
await handle.close();
}
};
export const readSkillFile = (path: string): Promise<string> =>
readRegularFileText(path, { symlinks: "reject" });
interface CanonicalSkillFile {
readonly content: string;
@@ -199,7 +191,7 @@ export const installCanonicalSkill = async (
for (const { destination, content } of changed)
await writeText(destination, content);
for (const { destination, content } of changed)
if ((await readFile(destination, "utf8")) !== content)
if ((await readSkillFile(destination)) !== content)
throw new Error(`skill readback mismatch: ${destination}`);
return "installed";
} catch (cause: unknown) {
@@ -231,7 +223,7 @@ export const readInstalledSkillIdentity = async (
let content: string | undefined;
for (const destination of destinations) {
try {
content = await readFile(join(destination.path, "SKILL.md"), "utf8");
content = await readSkillFile(join(destination.path, "SKILL.md"));
break;
} catch {
// Another selected copy may provide metadata; canonical validation below
+3 -2
View File
@@ -6,7 +6,7 @@ import {
withClientServers,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises";
import { copyFile, lstat, realpath, rm } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { join } from "node:path";
@@ -23,6 +23,7 @@ import {
} from "./SetupSkill.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { readRegularFileText } from "./RegularFileRead.js";
import {
manualRegistrationRemediation,
supportedClients,
@@ -49,7 +50,7 @@ export interface UninstallFileSystem {
}
const systemFileSystem: UninstallFileSystem = {
readText: (path) => readFile(path, "utf8"),
readText: (path) => readRegularFileText(path),
readRegularText: readSkillFile,
copy: (source, destination) =>
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
+5 -3
View File
@@ -1,4 +1,3 @@
import { readFile } from "node:fs/promises";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
@@ -14,6 +13,7 @@ import { supportedClients } from "./SupportedClients.js";
import { existingSkillDestinations } from "./SetupSkill.js";
import type { SetupAction } from "./SetupTypes.js";
import type { Result } from "../domain/result.js";
import { readRegularFileText } from "./RegularFileRead.js";
/** Existing REA integrations selected for maintenance, without discovering new targets. */
export interface MaintenanceScope {
@@ -73,7 +73,7 @@ export const existingMaintenanceScope = async (
const client = supported.find(({ name }) => name === registration.client);
if (client === undefined) continue;
const parsed = parseClientConfiguration(
await readFile(client.configPath, "utf8"),
await readRegularFileText(client.configPath),
client.format,
);
const enabled = z
@@ -130,7 +130,9 @@ export const planIntegrationMaintenance = async (
};
if (plan.data.plannedActions.length === 0)
return { status: "current", plannedActions: [] };
const allowedIds = new Set(scope.clients.map((client) => `configure_client:${client}`));
const allowedIds = new Set(
scope.clients.map((client) => `configure_client:${client}`),
);
const skillTargets = new Set(skillDestinations.map(({ path }) => path));
if (
plan.data.plannedActions.some((action) =>