Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
writePid,
writeRuntimePort,
} from "../config/process-state";
import { describeTrackedRuntimeTarget } from "../config/tracked-runtime-inspection";
import {
claimPendingTeardown,
isPendingTeardownAbandoned,
Expand Down Expand Up @@ -1020,13 +1021,7 @@ async function handleStop(approval?: StopApproval) {
try { return JSON.parse(lines[0]!) as ResolveJson; }
catch { return null; }
},
() => {
const pid = readPid();
const runtime = pid === null ? null : readRuntimePort(pid);
return pid && runtime?.port ? {
pid, port: runtime.port, hostname: runtime.hostname ?? "",
} : null;
},
describeTrackedRuntimeTarget,
() => inspectGuardedManagerTarget(approval.pid, approval.port),
snapshot => handleStopUnlocked(snapshot),
);
Expand Down
16 changes: 15 additions & 1 deletion src/cli/stop-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export interface GuardedStopSnapshot {
manager: Exclude<GuardedManagerTarget, { kind: "unknown" }>;
}

/**
* What the tracked-record check can report: the verified target, null when the
* caller cannot name a cause (the pre-diagnostics shape), or the fixed cause of
* the single check that failed. The cause is a fixed sentence fragment from the
* inspection probe -- it carries no path, command line, stderr, or token.
*/
export type TrackedRuntimeCheck =
| { pid: number; port: number; hostname: string }
| { untracked: string }
| null;

function sameGuardedManager(
expected: GuardedStopSnapshot["manager"],
current: GuardedManagerTarget,
Expand Down Expand Up @@ -117,7 +128,7 @@ export function managerStillActive(
export async function runApprovedStop(
expected: StopApproval,
read: () => Promise<ResolveJson | null>,
tracked: () => { pid: number; port: number; hostname: string } | null,
tracked: () => TrackedRuntimeCheck,
managerTarget: () => GuardedManagerTarget,
stop: (snapshot: GuardedStopSnapshot) => Promise<StopOutcome>,
): Promise<StopOutcome> {
Expand All @@ -130,6 +141,9 @@ export async function runApprovedStop(
const mismatch = stopApprovalMismatch(expected, now);
if (mismatch !== null) return approvalChanged(mismatch);
if (target === null) return approvalChanged("the tracked runtime record could not be verified");
if ("untracked" in target) {
return approvalChanged(`the tracked runtime record could not be verified (${target.untracked})`);
}
if (target.pid !== expected.pid || target.port !== expected.port
|| target.hostname !== expected.hostname) {
return approvalChanged("the tracked runtime record changed");
Expand Down
28 changes: 23 additions & 5 deletions src/config/process-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export type RuntimePortState = {
siblingOfPort?: number;
};

function isValidRuntimePortState(value: unknown): value is RuntimePortState {
export function isValidRuntimePortState(value: unknown): value is RuntimePortState {
if (!value || typeof value !== "object") return false;
const state = value as Record<string, unknown>;
const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
Expand Down Expand Up @@ -201,14 +201,32 @@ export function ocxStartProcessCacheSizeForTests(): number {
return ocxStartProcessCache.size;
}

function isLikelyOcxStartProcess(pid: number): boolean {
/**
* One observation of whether a candidate PID is an `ocx start` process.
*
* `readPid()` and `verifyPidIdentity()` ask this on every short liveness poll, and the
* read-only tracked-record inspection in `tracked-runtime-inspection.ts` asks it once per
* guarded stop, so all of them share this single cache-backed answer rather than probe
* twice: a hit performs no native command at all, and a miss performs exactly one
* {@link readProcessCommandLine} probe. Only a definite answer is cached; an unreadable
* command line is reported as `"unverifiable"`, left uncached, and re-probed on the next
* ask. `"unverifiable"` is not evidence of a permission boundary, a foreign process, or a
* different account or elevation — only that the probe could not answer this time.
*/
export type OcxStartIdentityObservation = "match" | "mismatch" | "unverifiable";

export function inspectOcxStartProcessIdentity(pid: number): OcxStartIdentityObservation {
const cached = ocxStartProcessCache.get(pid);
if (cached !== undefined) return cached;
if (cached !== undefined) return cached ? "match" : "mismatch";
const commandLine = readProcessCommandLine(pid);
if (commandLine === undefined) return false;
if (commandLine === undefined) return "unverifiable";
const ok = isOcxStartCommandLine(commandLine);
ocxStartProcessCache.set(pid, ok);
return ok;
return ok ? "match" : "mismatch";
}

function isLikelyOcxStartProcess(pid: number): boolean {
return inspectOcxStartProcessIdentity(pid) === "match";
}

export function readPid(): number | null {
Expand Down
188 changes: 188 additions & 0 deletions src/config/tracked-runtime-inspection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* Read-only classification of the tracked runtime record pair a guarded stop
* re-verifies before acting.
*
* "ocx stop --expect-*" asks "is the recorded runtime still the approved one",
* which collapses several observations into one boolean: a missing or invalid
* "ocx.pid", a dead, foreign or unverifiable recorded process, and a missing,
* invalid or PID-mismatched "runtime-port.json" all read as "not verified".
* The approval-changed refusal used to report only that umbrella fact, so a
* desktop log could not tell a stale record from a permission failure.
*
* This probe walks the same checks in the same order as "readPid()" followed
* by "readRuntimePort(pid)", but reports which one failed instead. Each state
* record is still read exactly once per call -- a guarded stop asks once, and
* caching the bytes would only carry a stale answer into a decision about a
* record that may have been rewritten. The process-identity question, though,
* is answered by the same cache-backed observation "readPid()" itself makes
* ("inspectOcxStartProcessIdentity"): a cached verdict serves this probe with
* no native command at all, and a miss performs the same single command-line
* probe readPid would, so this probe can never disagree with the admission
* readPid just granted or denied for the same PID. It performs no mutation, no
* signal beyond the same "process.kill(pid, 0)" liveness read, and no stop
* decision -- the refusal itself is unchanged; only its detail gains a cause.
*
* Two distinctions are deliberate:
*
* - "permission-denied" is reported only for a real EACCES/EPERM observed on a
* record read or the liveness signal. An "unverifiable" identity observation
* -- an unreadable process command line -- is not proof of a permission
* boundary, and no account or elevation cause is inferred from it.
* - No path, command line, stderr text, or token leaves this module: the refusal
* causes below are fixed strings and the detail built from them is a fixed
* sentence.
*/
import { readFileSync } from "node:fs";
import {
getPidPath,
getRuntimePortPath,
inspectOcxStartProcessIdentity,
isValidRuntimePortState,
parsePidFile,
} from "./process-state";
import type { OcxStartIdentityObservation } from "./process-state";

/** The single check that failed, as a stable machine-readable token. */
export type TrackedRecordRefusal =
| "pid-record-absent"
| "pid-record-permission-denied"
| "pid-record-unreadable"
| "pid-record-invalid"
| "process-not-running"
| "process-identity-mismatch"
| "process-unverifiable"
| "runtime-record-absent"
| "runtime-record-permission-denied"
| "runtime-record-unreadable"
| "runtime-record-invalid"
| "runtime-record-pid-mismatch"
| "unverified";

export type TrackedRecordInspection =
| { status: "verified"; pid: number; port: number; hostname: string }
| { status: "refused"; reason: TrackedRecordRefusal };

/**
* Injectable read-only seams. Tests must supply fakes for "signal" and either
* "identity" or the process-state exec seams ("setProcessCommandLineExecForTests"
* and friends) so no real "process.kill" or native command probe runs; the
* defaults are exactly what the production caller uses, including the shared
* identity cache.
*/
export interface TrackedRecordProbeDeps {
/** Read a state record like "readFileSync(path, 'utf-8')"; throw with errno codes. */
readFile?: (path: string) => string;
/** The "process.kill(pid, 0)" liveness read; throws like it (ESRCH/EPERM/...). */
signal?: (pid: number) => void;
/** The shared start-identity observation; the default consults the real cache. */
identity?: (pid: number) => OcxStartIdentityObservation;
}

function readRecordFile(
readFile: (path: string) => string,
path: string,
absent: TrackedRecordRefusal,
denied: TrackedRecordRefusal,
unreadable: TrackedRecordRefusal,
): { ok: true; raw: string } | { ok: false; reason: TrackedRecordRefusal } {
try {
return { ok: true, raw: readFile(path) };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EACCES" || code === "EPERM") return { ok: false, reason: denied };
if (code === "ENOENT") return { ok: false, reason: absent };
return { ok: false, reason: unreadable };
}
}

/**
* Classify why the tracked runtime record cannot back a guarded stop, or return
* the verified target when every check still holds. One read per record, in the
* same order the stop path already performs them.
*/
export function inspectTrackedRuntimeRecord(
deps: TrackedRecordProbeDeps = {},
): TrackedRecordInspection {
const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf-8"));
const signal = deps.signal ?? ((pid: number) => { process.kill(pid, 0); });
const identity = deps.identity ?? inspectOcxStartProcessIdentity;
try {
const pidFile = readRecordFile(
readFile, getPidPath(), "pid-record-absent", "pid-record-permission-denied", "pid-record-unreadable",
);
if (!pidFile.ok) return { status: "refused", reason: pidFile.reason };
const pid = parsePidFile(pidFile.raw);
if (pid === null) return { status: "refused", reason: "pid-record-invalid" };
try {
signal(pid);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ESRCH") return { status: "refused", reason: "process-not-running" };
// EPERM still names a live process; identity is decided by the shared
// observation below, exactly as readPid decides it. Any other signal
// error can only be reported as unverifiable.
if (code !== "EPERM") return { status: "refused", reason: "process-unverifiable" };
}
const observation = identity(pid);
if (observation === "unverifiable") {
return { status: "refused", reason: "process-unverifiable" };
}
if (observation === "mismatch") {
return { status: "refused", reason: "process-identity-mismatch" };
}
const runtimeFile = readRecordFile(
readFile, getRuntimePortPath(),
"runtime-record-absent", "runtime-record-permission-denied", "runtime-record-unreadable",
);
if (!runtimeFile.ok) return { status: "refused", reason: runtimeFile.reason };
let parsed: unknown;
try {
parsed = JSON.parse(runtimeFile.raw);
} catch {
return { status: "refused", reason: "runtime-record-invalid" };
}
if (!isValidRuntimePortState(parsed)) {
return { status: "refused", reason: "runtime-record-invalid" };
}
if (parsed.pid !== pid) {
return { status: "refused", reason: "runtime-record-pid-mismatch" };
}
return { status: "verified", pid, port: parsed.port, hostname: parsed.hostname ?? "" };
} catch {
return { status: "refused", reason: "unverified" };
}
}

/**
* Fixed refusal causes for the approval-changed detail. Every entry is a
* complete sentence fragment and contains no path, command line, stderr, or
* token, so the resulting detail is safe to surface in a caller's log or UI.
*/
const TRACKED_REFUSAL_CAUSES: Record<TrackedRecordRefusal, string> = {
"pid-record-absent": "the recorded PID file is absent",
"pid-record-permission-denied": "reading the recorded PID file was denied",
"pid-record-unreadable": "the recorded PID file could not be read",
"pid-record-invalid": "the recorded PID file is not a valid record",
"process-not-running": "the recorded process is no longer running",
"process-identity-mismatch": "the recorded process does not match the expected start command",
"process-unverifiable": "the recorded process identity could not be verified",
"runtime-record-absent": "the tracked runtime record is absent",
"runtime-record-permission-denied": "reading the tracked runtime record was denied",
"runtime-record-unreadable": "the tracked runtime record could not be read",
"runtime-record-invalid": "the tracked runtime record is not a valid record",
"runtime-record-pid-mismatch": "the tracked runtime record names a different PID",
"unverified": "the tracked runtime record could not be re-verified",
};

/**
* The guarded stop's tracked() callback shape: the verified target, or the
* fixed cause of the single check that failed. Read-only; decides nothing.
*/
export function describeTrackedRuntimeTarget(
deps: TrackedRecordProbeDeps = {},
): { pid: number; port: number; hostname: string } | { untracked: string } {
const inspection = inspectTrackedRuntimeRecord(deps);
return inspection.status === "verified"
? { pid: inspection.pid, port: inspection.port, hostname: inspection.hostname }
: { untracked: TRACKED_REFUSAL_CAUSES[inspection.reason] };
}
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ silently move the active installation. Config reads in this leaf and `src/config
It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command
identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and `siblingOfPort` (the live owner's port, written only by a sibling instance) remain optional,
owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same
symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly.
symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. `src/config/tracked-runtime-inspection.ts` is the guarded stop's read-only companion probe: it re-runs the same record reads in the same order, answers the start-identity question through the same cache `readPid()` consults, and reports which single check failed as a fixed refusal cause, so an absent, denied, invalid or PID-mismatched record is told apart from a dead, foreign or unverifiable process, and it decides nothing and exposes no path, command line, stderr, or token.

Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Default writers track temporary paths per resolved destination across synchronous, streamed and asynchronous writes. A new write refuses immediately with sanitized `ETIMEDOUT` only while one of that destination's tracked paths has an icacls child retained past its caller's timeout belt and not yet reaped, including across re-arm windows. Healthy overlapping writes proceed, including during ACL hardening or a pause before rename. Refusal consumes no recovery attempt and leaves removal ownership intact; completing one write does not drop another write's retained-child tracking. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary, healthy overlap, retained child admission and cleanup. The leaf preserves the shared
process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, symlink target resolution, no-follow directory-entry replacement for
Expand Down
Loading
Loading