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
25 changes: 25 additions & 0 deletions docs/doctoring/external-scheduler-descriptor-stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# External Scheduler Evidence Descriptor Stability

## Scope

This note records the filesystem-integrity basis for `readExternalSchedulerEvidence()` in `scripts/external-scheduler-evidence-audit.mjs`. The operator consumes a small retained scheduler-evidence file and must fail closed when the opened filesystem object changes while its bytes are being collected.

## Reviewed boundary

Noema already opens the evidence file read-only with `O_NOFOLLOW`, rejects non-regular files, and caps accepted input at 262,144 bytes. POSIX.1-2024 defines `O_NOFOLLOW` at the `open()` boundary, and Node.js exposes `fs.fstatSync()` so metadata can be retrieved from the file descriptor actually being read rather than from a separately resolved pathname.

A pre-read descriptor observation alone cannot demonstrate that descriptor metadata stayed stable through the read. The reader therefore obtains `fs.Stats` both before and after `readFileSync(fd)` and rejects the snapshot when regular-file status, device, inode, byte size, modification time, or status-change time differs. The existing exact-byte-length check remains in front of this second metadata observation, and the descriptor is closed through the existing `finally` path on success and failure.

This is a bounded tamper/change-detection control for retained local evidence. It does not establish host privilege isolation, authenticated scheduler-provider provenance, production execution truth, release/deployment evidence, or acquisition readiness. Filesystem timestamp granularity and higher-authority storage mutation remain environmental limitations; upstream identity and cryptographic provenance must be proven separately.

## Test contract

`test/external-scheduler-evidence-post-read-stability.test.ts` supplies deterministic descriptor metadata before and after the read. It rejects post-read non-file state and drift in device, inode, size, modification time, or status-change time, while the existing real-file happy path proves unchanged bounded evidence remains readable. Because `scripts/external-scheduler-evidence-audit.mjs` is part of the configured owned-production coverage set, the new branches are required to remain covered by the repository's 100% statement/branch/function/line threshold.

## References

Institute of Electrical and Electronics Engineers, & The Open Group. (2024). *The Open Group Base Specifications Issue 8, IEEE Std 1003.1-2024: open*. The Open Group. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html

National Institute of Standards and Technology. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218 Rev. 1, Initial Public Draft). https://csrc.nist.gov/pubs/sp/800/218/r1/ipd

OpenJS Foundation. (2026). *Node.js v26.1.0 documentation: File system (`fs.fstatSync`, `fs.Stats`)*. https://nodejs.org/download/release/v26.1.0/docs/api/fs.html
16 changes: 15 additions & 1 deletion scripts/external-scheduler-evidence-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export function sanitizeReportText(value) {
: `${text.slice(0, MAX_ERROR_CHARS - 1)}…`;
}

/** Read one regular, no-follow, size-bounded UTF-8 JSON evidence file. */
/**
* Read one regular, no-follow, size-bounded UTF-8 JSON evidence file and
* reject descriptor metadata drift observed after the bytes are consumed.
*/
export function readExternalSchedulerEvidence(path, io = defaultReadIo) {
const absolutePath = resolve(path);
let descriptor;
Expand All @@ -82,6 +85,17 @@ export function readExternalSchedulerEvidence(path, io = defaultReadIo) {
if (bytes.byteLength !== stats.size) {
throw new Error("External scheduler evidence changed while it was being read.");
}
const finalStats = io.fstatSync(descriptor);
if (
!finalStats.isFile()
|| finalStats.dev !== stats.dev
|| finalStats.ino !== stats.ino
|| finalStats.size !== stats.size
|| finalStats.mtimeMs !== stats.mtimeMs
|| finalStats.ctimeMs !== stats.ctimeMs
) {
throw new Error("External scheduler evidence changed while it was being read.");
}
const text = fatalUtf8Decoder.decode(bytes);
if (hasDuplicateJsonObjectKeys(text)) {
throw new Error(
Expand Down
60 changes: 60 additions & 0 deletions test/external-scheduler-evidence-post-read-stability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";

const cliUrl = new URL(
"../scripts/external-scheduler-evidence-audit.mjs",
import.meta.url,
);

async function loadCli() {
return await import(cliUrl.href) as Record<string, any>;
}

type MetadataOverrides = Partial<{
dev: number;
ino: number;
size: number;
mtimeMs: number;
ctimeMs: number;
file: boolean;
}>;

function metadata(overrides: MetadataOverrides = {}) {
return {
dev: overrides.dev ?? 11,
ino: overrides.ino ?? 13,
size: overrides.size ?? 2,
mtimeMs: overrides.mtimeMs ?? 17,
ctimeMs: overrides.ctimeMs ?? 19,
isFile: () => overrides.file ?? true,
};
}

describe("external scheduler evidence descriptor post-read stability", () => {
it.each([
{ label: "non-file metadata", finalMetadata: metadata({ file: false }) },
{ label: "device drift", finalMetadata: metadata({ dev: 99 }) },
{ label: "inode drift", finalMetadata: metadata({ ino: 99 }) },
{ label: "size drift", finalMetadata: metadata({ size: 3 }) },
{ label: "mtime drift", finalMetadata: metadata({ mtimeMs: 23 }) },
{ label: "ctime drift", finalMetadata: metadata({ ctimeMs: 29 }) },
])("rejects $label after bytes are read", async ({ finalMetadata }) => {
const cli = await loadCli();
const closed: number[] = [];
const fstatSync = vi
.fn()
.mockReturnValueOnce(metadata())
.mockReturnValueOnce(finalMetadata);
const io = {
openSync: () => 31,
fstatSync,
readFileSync: () => Buffer.from("{}", "utf8"),
closeSync: (descriptor: number) => closed.push(descriptor),
};

expect(() => cli.readExternalSchedulerEvidence("ignored.json", io)).toThrow(
"changed while it was being read",
);
expect(fstatSync).toHaveBeenCalledTimes(2);
expect(closed).toEqual([31]);
});
});
Loading