Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
73e4d76
test(operations): reject scheduler evidence path collision
seonghobae Aug 31, 2026
5003446
fix(operations): preserve scheduler source evidence
seonghobae Aug 31, 2026
8cae9dc
test(operations): reject symlinked scheduler report parent
seonghobae Aug 31, 2026
99ad9b8
fix(operations): harden scheduler audit report output
seonghobae Aug 31, 2026
3676583
test(operations): reject symlinked scheduler evidence parent
seonghobae Aug 31, 2026
f19de86
fix(operations): harden scheduler evidence input authority
seonghobae Aug 31, 2026
6be9bab
merge main into scheduler evidence hardening
seonghobae Aug 31, 2026
adb850b
test(operations): reject scheduler evidence inode aliases
seonghobae Aug 31, 2026
6ca24a3
fix(operations): reject scheduler evidence filesystem aliases
seonghobae Aug 31, 2026
0e9ca26
test(operations): reject hardlinked scheduler source evidence
seonghobae Aug 31, 2026
0a5490f
fix(operations): reject multiply-linked scheduler source evidence
seonghobae Aug 31, 2026
3cf2d25
test(operations): cover scheduler source link-count drift
seonghobae Aug 31, 2026
c1d1cf2
test(operations): keep scheduler descriptor fixture valid
seonghobae Aug 31, 2026
3a40eb2
test(operations): reject deceptive scheduler diagnostics
seonghobae Aug 31, 2026
22c6238
fix(operations): sanitize retained scheduler diagnostics
seonghobae Aug 31, 2026
dea61bf
docs(operations): record scheduler evidence hardening
seonghobae Aug 31, 2026
fa638f2
docs(operations): preserve existing changelog escape
seonghobae Aug 31, 2026
081f901
test(operations): reject retained evidence leaf replacement
seonghobae Aug 31, 2026
8077fa1
fix(operations): bind audit to retained evidence leaf
seonghobae Aug 31, 2026
6c7eb45
test(operations): cover retained leaf post-close authority
seonghobae Aug 31, 2026
ac2fbc6
test(operations): close scheduler evidence coverage gaps
seonghobae Aug 31, 2026
0ca46dd
merge: converge scheduler evidence audit with protected main
seonghobae Aug 31, 2026
1ed38b6
fix(operations): preserve scheduler source at publication
seonghobae Aug 31, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed.
- production runtime credential envelope parsing을 fail-closed로 강화한다. GitHub App PKCS#1 key의 canonical PKCS#8 변환은 유지하되, bare carriage return처럼 비정규 body bytes가 포함된 PKCS#8 PEM은 readiness/import 단계의 암묵적 정규화에 넘기지 않고 즉시 거부해 malformed secret이 ready 상태로 승인되지 않게 한다.
- Governance and Maintainer App GitHub CLI subprocesses now keep CLI config and XDG state inside the validated capability file's private parent directory, preventing a missing ambient home from writing `.local` state into the checkout.
- Maintainer App readiness now requires the retained governance audit's `protected_main_sha` to equal the freshly collected default-branch head, preventing evidence from different protected-main revisions from being combined into one passing report. The governance collector authenticates every tracked checkout byte against exact HEAD before and after live collection, so modified audit source cannot emit PASS evidence attributed to protected main. Governance and readiness report paths also retain their existing non-symlink private-output authority. The product/technical gap baseline is refreshed to the same protected-main and live issue/run/release observation, and describes the hourly loop through `contextual-orchestrator` rather than retired direct-provider execution.
Expand Down
99 changes: 93 additions & 6 deletions scripts/external-scheduler-evidence-audit.mjs
100644 → 100755
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
closeSync,
constants,
fstatSync,
lstatSync,
mkdirSync,
mkdtempSync,
openSync,
Expand All @@ -13,6 +14,10 @@ import {
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
assertAcquisitionPrivatePathParents,
writeAcquisitionPrivateFile,
} from "./lib/acquisition-private-output.mjs";
import { evaluateExternalSchedulerEvidence } from "./lib/external-scheduler-evidence-audit.mjs";
import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs";

Expand All @@ -25,6 +30,7 @@ const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true });
const defaultReadIo = {
openSync,
fstatSync,
lstatSync,
readFileSync,
closeSync,
};
Expand Down Expand Up @@ -53,20 +59,26 @@ export function sanitizeReportText(value) {
? value
: "";
const text = rawText
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/[\u0000-\u001f\u007f\u2028\u2029]|\p{Cf}/gu, "")
.trim();
return text.length <= MAX_ERROR_CHARS
? text
: `${text.slice(0, MAX_ERROR_CHARS - 1)}…`;
}

/**
* Read one regular, no-follow, size-bounded UTF-8 JSON evidence file and
* reject descriptor metadata drift observed after the bytes are consumed.
* Read one regular, single-link, no-follow, size-bounded UTF-8 JSON evidence file and
* reject descriptor metadata, retained-leaf identity, or parent-path authority drift
* before returning parsed evidence.
*/
export function readExternalSchedulerEvidence(path, io = defaultReadIo) {
const absolutePath = resolve(path);
if (io === defaultReadIo) {
assertAcquisitionPrivatePathParents(absolutePath);
}
let descriptor;
let acceptedMetadata;
let parsedEvidence;
try {
descriptor = io.openSync(
absolutePath,
Expand All @@ -76,6 +88,9 @@ export function readExternalSchedulerEvidence(path, io = defaultReadIo) {
if (!stats.isFile()) {
throw new Error("External scheduler evidence must be a regular file.");
}
if (stats.nlink !== 1) {
throw new Error("External scheduler evidence must have exactly one filesystem link.");
}
if (stats.size <= 0 || stats.size > MAX_EVIDENCE_BYTES) {
throw new Error(
`External scheduler evidence must contain 1 through ${MAX_EVIDENCE_BYTES} bytes.`,
Expand All @@ -88,6 +103,7 @@ export function readExternalSchedulerEvidence(path, io = defaultReadIo) {
const finalStats = io.fstatSync(descriptor);
if (
!finalStats.isFile()
|| finalStats.nlink !== 1
|| finalStats.dev !== stats.dev
|| finalStats.ino !== stats.ino
|| finalStats.size !== stats.size
Expand All @@ -96,29 +112,75 @@ export function readExternalSchedulerEvidence(path, io = defaultReadIo) {
) {
throw new Error("External scheduler evidence changed while it was being read.");
}
if (io === defaultReadIo) {
assertAcquisitionPrivatePathParents(absolutePath);
}
const text = fatalUtf8Decoder.decode(bytes);
if (hasDuplicateJsonObjectKeys(text)) {
throw new Error(
"External scheduler evidence contains duplicate decoded JSON object keys.",
);
}
return JSON.parse(text);
parsedEvidence = JSON.parse(text);
acceptedMetadata = finalStats;
} finally {
if (descriptor !== undefined) io.closeSync(descriptor);
}

if (typeof io.lstatSync === "function") {
if (io === defaultReadIo) {
assertAcquisitionPrivatePathParents(absolutePath);
}
const retainedMetadata = io.lstatSync(
absolutePath,
{ throwIfNoEntry: false },
) ?? null;
if (
!retainedMetadata
|| !retainedMetadata.isFile()
|| retainedMetadata.nlink !== 1
|| retainedMetadata.dev !== acceptedMetadata.dev
|| retainedMetadata.ino !== acceptedMetadata.ino
|| retainedMetadata.size !== acceptedMetadata.size
|| retainedMetadata.mtimeMs !== acceptedMetadata.mtimeMs
|| retainedMetadata.ctimeMs !== acceptedMetadata.ctimeMs
) {
throw new Error("External scheduler retained pathname changed after it was read.");
}
if (io === defaultReadIo) {
assertAcquisitionPrivatePathParents(absolutePath);
}
}

return parsedEvidence;
}

/** Atomically publish a private JSON report and always remove temporary state. */
/** Atomically publish a private JSON report without following unsafe output authority. */
export function writeAtomicJson(path, value, io = defaultWriteIo) {
const absolutePath = resolve(path);
const directory = dirname(absolutePath);
const contents = `${JSON.stringify(value, null, 2)}\n`;

if (io === defaultWriteIo) {
assertAcquisitionPrivatePathParents(absolutePath);
io.mkdirSync(directory, { recursive: true, mode: 0o700 });
assertAcquisitionPrivatePathParents(absolutePath);
// The audit report is a one-shot receipt. Exclusive creation is required:
// replacing an existing target could destroy the accepted source inode if
// another process moved that inode onto the report pathname after reading.
writeAcquisitionPrivateFile(absolutePath, contents, undefined, {
replaceExisting: false,
});
return absolutePath;
}

io.mkdirSync(directory, { recursive: true });
const temporaryDirectory = io.mkdtempSync(join(directory, ".scheduler-audit-"));
const temporaryPath = join(temporaryDirectory, "report.json");
try {
io.writeFileSync(
temporaryPath,
`${JSON.stringify(value, null, 2)}\n`,
contents,
{ encoding: "utf8", mode: 0o600, flag: "wx" },
);
io.renameSync(temporaryPath, absolutePath);
Expand All @@ -141,6 +203,30 @@ export function resolveCliPaths(env, argv) {
};
}

/** Refuse one filesystem object from serving as both retained source evidence and audit output. */
export function assertDistinctEvidenceAndReportPaths(evidencePath, reportPath) {
const absoluteEvidencePath = resolve(evidencePath);
const absoluteReportPath = resolve(reportPath);
if (absoluteEvidencePath === absoluteReportPath) {
throw new Error(
"External scheduler evidence and audit report must resolve to different paths.",
);
}

const evidenceMetadata = lstatSync(absoluteEvidencePath, { throwIfNoEntry: false }) ?? null;
const reportMetadata = lstatSync(absoluteReportPath, { throwIfNoEntry: false }) ?? null;
if (
evidenceMetadata
&& reportMetadata
&& evidenceMetadata.dev === reportMetadata.dev
&& evidenceMetadata.ino === reportMetadata.ino
) {
throw new Error(
"External scheduler evidence and audit report must identify different filesystem objects.",
);
}
}

/** Build a bounded collection-failure report without retaining raw evidence. */
export function createFailureReport(error, generatedAt) {
return {
Expand Down Expand Up @@ -202,6 +288,7 @@ export function main(options = {}) {
process.exitCode = code;
});
const { evidencePath, reportPath } = resolveCliPaths(env, argv);
assertDistinctEvidenceAndReportPaths(evidencePath, reportPath);
Comment thread
seonghobae marked this conversation as resolved.
const generatedAt = now();

let report;
Expand Down
5 changes: 5 additions & 0 deletions scripts/lib/acquisition-private-output.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export function writeAcquisitionPrivateFile(
path,
contents,
fileSystem = defaultFileSystem,
options = {},
) {
if (typeof path !== "string" || path.length === 0 || typeof contents !== "string") {
throw new TypeError("acquisition output requires a non-empty path and UTF-8 text");
Expand Down Expand Up @@ -317,6 +318,10 @@ export function writeAcquisitionPrivateFile(
return;
}

if (options.replaceExisting === false) {
throw new Error("acquisition output target must not already exist");
}

if (typeof fileSystem.renameSync !== "function" || typeof fileSystem.unlinkSync !== "function") {
throw new Error("acquisition output replacement requires atomic rename filesystem support");
}
Expand Down
1 change: 1 addition & 0 deletions test/external-scheduler-evidence-cli-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ describe("external scheduler evidence CLI production defaults", () => {
expect(JSON.parse(readFileSync(reportPath, "utf8")).status).toBe("PASS");

rmSync("external-scheduler-evidence.json");
rmSync(reportPath);
const failReport = cli.main();

expect(failReport.status).toBe("FAIL");
Expand Down
2 changes: 1 addition & 1 deletion test/external-scheduler-evidence-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe("external scheduler evidence CLI", () => {
const closed: number[] = [];
const io = {
openSync: () => 17,
fstatSync: () => ({ isFile: () => true, size: 3 }),
fstatSync: () => ({ isFile: () => true, nlink: 1, size: 3 }),
readFileSync: () => Buffer.from("{}", "utf8"),
closeSync: (descriptor: number) => closed.push(descriptor),
};
Expand Down
Loading
Loading