Skip to content
Merged
Show file tree
Hide file tree
Changes from 92 commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
e424ba4
test(operations): reject unbounded workflow registry streams
seonghobae Aug 28, 2026
d89d8aa
fix(operations): stream-bound workflow registry responses
seonghobae Aug 28, 2026
9e358c5
test(operations): reject BOM-normalized workflow registry JSON
seonghobae Aug 28, 2026
d54293b
fix(operations): preserve BOM bytes for workflow registry JSON
seonghobae Aug 28, 2026
e153f60
test(operations): require JSON media type for registry authority
seonghobae Aug 28, 2026
992bd30
fix(operations): bind workflow registry JSON to declared media type
seonghobae Aug 28, 2026
743c133
test(operations): align registry fixtures with JSON authority
seonghobae Aug 28, 2026
a80735b
test(operations): align registry fixtures with JSON authority
seonghobae Aug 28, 2026
505a190
test(operations): align registry fixtures with JSON authority
seonghobae Aug 28, 2026
9ea8c79
test(operations): reject unreviewed registry JSON parameters
seonghobae Aug 28, 2026
0737aa4
fix(operations): restrict registry JSON parameters to UTF-8
seonghobae Aug 28, 2026
a79f9cb
test(operations): bound transport response streaming
seonghobae Aug 28, 2026
0cad32e
fix(operations): bound transport response streaming
seonghobae Aug 28, 2026
4839fcd
test(operations): reject ambiguous transport JSON authority
seonghobae Aug 28, 2026
5669612
fix(operations): bind transport JSON authority
seonghobae Aug 28, 2026
f1ae91b
test(operations): align oversized transport fixture
seonghobae Aug 28, 2026
58ce841
test(operations): bound transport cancellation failures
seonghobae Aug 28, 2026
a76f480
test(operations): bound live reader cancellation failures
seonghobae Aug 28, 2026
eed5092
fix(operations): preserve bounded stream rejection
seonghobae Aug 28, 2026
88841a6
fix(operations): preserve transport bound rejection
seonghobae Aug 28, 2026
a72799b
test(operations): cover bodyless transport size bound
seonghobae Aug 28, 2026
f9c9251
test(auth): reject ambiguous delegated token bytes
seonghobae Aug 28, 2026
de2c644
fix(auth): preserve exact delegated token bytes
seonghobae Aug 28, 2026
0a11843
test(security): reject trimmed capability paths
seonghobae Aug 28, 2026
1dc916d
fix(security): preserve delegated capability path bytes
seonghobae Aug 28, 2026
9143909
test(operations): preserve raw capability path at CLI
seonghobae Aug 28, 2026
e9463e9
fix(operations): preserve raw capability path authority
seonghobae Aug 28, 2026
1c58dff
test(operations): reject normalized repository identity
seonghobae Aug 28, 2026
48fbfa7
fix(operations): preserve raw repository identity
seonghobae Aug 28, 2026
61396d2
test(operations): reject non-canonical workflow ids
seonghobae Aug 28, 2026
6a59572
fix(operations): preserve canonical workflow-id authority
seonghobae Aug 28, 2026
3e4448e
test(operations): preserve live-audit authority bytes
seonghobae Aug 28, 2026
d119322
fix(operations): preserve live-audit authority bytes
seonghobae Aug 28, 2026
13daa8f
test(operations): align repository-boundary regression
seonghobae Aug 28, 2026
22f7be1
test(operations): redact control-split GitHub token diagnostics
seonghobae Aug 28, 2026
8d1b5e1
fix(operations): redact credentials after control cleanup
seonghobae Aug 28, 2026
d0dfe12
test(operations): align bearer redaction contract
seonghobae Aug 28, 2026
95f56d6
test(operations): redact control-split delegated token diagnostics
seonghobae Aug 28, 2026
4f815ee
fix(operations): redact delegated token diagnostics after control rem…
seonghobae Aug 28, 2026
d7826e1
test(operations): reject aliased delegated capability paths
seonghobae Aug 28, 2026
acc0af0
fix(operations): require canonical delegated capability paths
seonghobae Aug 28, 2026
ee1199c
test(security): reject hard-linked delegated token capability
seonghobae Aug 28, 2026
73dac48
fix(security): reject hard-linked delegated token capability
seonghobae Aug 28, 2026
a045c94
test(operations): keep deleted workflows orphan-visible
seonghobae Aug 28, 2026
4853cfd
fix(operations): keep deleted workflows orphan-visible
seonghobae Aug 28, 2026
eecccda
test(operations): reject control bytes in workflow authority
seonghobae Aug 28, 2026
bd43ec3
fix(operations): reject control bytes in workflow authority
seonghobae Aug 28, 2026
a57a16a
test(operations): reject symlink-parent token capability
seonghobae Aug 28, 2026
82c3133
fix(operations): reject symlink-parent token capability
seonghobae Aug 28, 2026
bb8d42d
test(operations): require exact workflow registry HTTP success
seonghobae Aug 28, 2026
6814f41
fix(operations): require authoritative workflow registry HTTP 200
seonghobae Aug 28, 2026
9b04ebc
test(operations): reject malformed protected-main SHA authority
seonghobae Aug 28, 2026
64f9913
fix(operations): validate protected-main commit authority
seonghobae Aug 28, 2026
932f3e6
test(operations): reject unsafe post-disablement audit residuals
seonghobae Aug 29, 2026
bf19501
fix(operations): reject unauthoritative post-audit residuals
seonghobae Aug 29, 2026
1e4e030
test(operations): align residual receipt fixture with strict identities
seonghobae Aug 29, 2026
1f205d6
test(operations): expect fail-closed residual classification
seonghobae Aug 29, 2026
ba8c24f
test(operations): cover contradictory PASS residual audit
seonghobae Aug 29, 2026
1c7c408
test(operations): reject duplicate post-audit orphan evidence
seonghobae Aug 29, 2026
2e5816d
fix(operations): reject duplicate post-audit orphan evidence
seonghobae Aug 29, 2026
b7975b4
Merge 2e5816d6daa56be90472e3b1f34069a1a893ad92 into 9fd64b184a7df5292…
seonghobae Aug 29, 2026
f0d5693
test(operations): bound workflow response deadline
seonghobae Aug 30, 2026
1ab76f3
test(operations): bound stalled registry transport
seonghobae Aug 30, 2026
ca344cf
fix(operations): enforce registry request deadline
seonghobae Aug 30, 2026
e7ba8f2
test(operations): cover deadline cleanup authority
seonghobae Aug 30, 2026
9199a48
test(operations): cover registry deadline edge paths
seonghobae Aug 30, 2026
c731493
test(operations): cover live fallback read failure
seonghobae Aug 30, 2026
04d5cb7
test(operations): reproduce disablement transport deadline bypass
seonghobae Aug 30, 2026
61daa65
fix(operations): enforce disablement transport deadline
seonghobae Aug 30, 2026
e765044
test(operations): cover deadline cleanup authority
seonghobae Aug 30, 2026
e473593
test(operations): close disablement deadline branch evidence
seonghobae Aug 30, 2026
00b6b0a
test(operations): recheck orphan ownership before disablement
seonghobae Aug 30, 2026
0c6c470
fix(operations): refresh orphan authority before disablement
seonghobae Aug 30, 2026
e30313c
test(operations): align disablement authority refresh fixtures
seonghobae Aug 30, 2026
d971b70
test(operations): align post-audit branch fixtures
seonghobae Aug 30, 2026
7e2ec18
test(operations): refresh coverage fixtures before mutation
seonghobae Aug 30, 2026
b6dd46d
test(operations): preserve post-audit integrity after authority refresh
seonghobae Aug 30, 2026
5759341
test(operations): retain residual checks after authority refresh
seonghobae Aug 30, 2026
2369047
test(operations): cover pre-mutation authority failures
seonghobae Aug 30, 2026
4d6aac5
test(operations): cover malformed pre-mutation audit
seonghobae Aug 30, 2026
53695c8
test(operations): reject unstreamable registry responses
seonghobae Aug 30, 2026
3b7f081
fix(operations): reject unstreamable disablement responses
seonghobae Aug 30, 2026
9994d00
fix(operations): reject unstreamable registry evidence
seonghobae Aug 30, 2026
c6ea4c7
test(operations): make registry reader fixtures stream-realistic
seonghobae Aug 30, 2026
7c9759b
test(operations): retire unsafe disablement arrayBuffer fixtures
seonghobae Aug 30, 2026
e8d1a9d
test(operations): stream realistic live-disable API fixtures
seonghobae Aug 30, 2026
816eec2
test(operations): fail closed on unstreamable live responses
seonghobae Aug 30, 2026
eab59bd
test(operations): retire live registry arrayBuffer fallback
seonghobae Aug 30, 2026
eebd229
test(operations): stream branch-coverage response fixtures
seonghobae Aug 30, 2026
c9370eb
test(operations): retire unsafe registry buffer fallback assumptions
seonghobae Aug 30, 2026
b7ce977
test(operations): reject unstreamable workflow registry responses
seonghobae Aug 30, 2026
150452e
test(operations): cover synchronous registry cleanup
seonghobae Aug 30, 2026
eb74c30
Merge main (#500 OpenSSL 3.5.8 patch-validator fix) into fix/workflow…
claude Aug 30, 2026
00ba549
Merge main (#503 governance capability-path fix) into fix/workflow-re…
claude Aug 30, 2026
6b06143
test(workflow-registry): cover expired and rejected response authority
seonghobae Aug 30, 2026
80b7e6b
fix(workflow-registry): preserve response authority through parsing
seonghobae Aug 30, 2026
05e42f2
fix(workflow-registry): clean rejected live response bodies
seonghobae Aug 30, 2026
d322987
test(operations): cancel rejected GitHub response bodies
seonghobae Aug 30, 2026
c53f58d
fix(operations): cancel rejected GitHub response bodies
seonghobae Aug 30, 2026
223311b
fix(operations): cancel live-registry status rejects
seonghobae Aug 30, 2026
85ab7fb
merge(main): converge workflow registry authority lane
seonghobae Aug 30, 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
38 changes: 32 additions & 6 deletions scripts/lib/delegated-github-token.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,25 @@ import {
fstatSync,
openSync,
readSync,
realpathSync,
} from "node:fs";
import { dirname, normalize } from "node:path";

const MAX_DELEGATED_TOKEN_BYTES = 16 * 1024;

function boundedFileError(error) {
return String(error?.message ?? error)
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/\bbearer\s+\S+/gi, "Bearer [REDACTED]")
.replace(/\b(?:github_pat_|gh[pousr]_)[A-Za-z0-9_]+\b/g, "[REDACTED]")
.replace(/[\u0000-\u001f\u007f]/g, "")
.slice(0, 1_024);
}

function sameFileVersion(left, right) {
return (
left.dev === right.dev
&& left.ino === right.ino
&& left.nlink === right.nlink
&& left.size === right.size
&& left.mtimeNs === right.mtimeNs
&& left.ctimeNs === right.ctimeNs
Expand All @@ -31,19 +34,34 @@ function sameFileVersion(left, right) {
*
* The file path is non-secret runtime configuration. The bearer token itself
* must not be read from the Node process environment. The reader fails closed
* unless the capability is a bounded, owner-only, regular file opened without
* following symlinks and remains the same descriptor version throughout the read.
* Callers remain responsible for trusted bootstrap creation and prompt cleanup.
* unless the capability is a bounded, owner-only, single-link regular file
* opened without following symlinks and remains the same descriptor version
* throughout the read. Callers remain responsible for trusted bootstrap
* creation and prompt cleanup.
*/
export function readDelegatedGithubToken(tokenPath) {
const path = String(tokenPath ?? "").trim();
const path = String(tokenPath ?? "");
if (!path) {
throw new Error("Maintainer token file path is required.");
}
if (path !== path.trim() || normalize(path) !== path) {
throw new Error("Maintainer token file path must be lexically canonical.");
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
}
if (!Number.isInteger(constants.O_NOFOLLOW)) {
throw new Error("Maintainer token capability requires no-follow file support.");
}

const parentPath = dirname(path);
let resolvedParentPath;
try {
resolvedParentPath = realpathSync.native(parentPath);
} catch (error) {
throw new Error(`Maintainer token file could not be opened safely: ${boundedFileError(error)}`);
}
if (resolvedParentPath !== parentPath) {
throw new Error("Maintainer token file path must be lexically canonical.");
}

let descriptor;
try {
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
Expand All @@ -56,6 +74,9 @@ export function readDelegatedGithubToken(tokenPath) {
if (!before.isFile()) {
throw new Error("Maintainer token capability must be a regular file.");
}
if (before.nlink !== 1n) {
throw new Error("Maintainer token capability must have exactly one filesystem link.");
}
if ((before.mode & 0o077n) !== 0n) {
throw new Error("Maintainer token file permissions must be owner-only.");
}
Expand Down Expand Up @@ -93,7 +114,9 @@ export function readDelegatedGithubToken(tokenPath) {

let token;
try {
token = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead));
token = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
buffer.subarray(0, bytesRead),
);
} catch {
throw new Error("Maintainer token file contains invalid UTF-8.");
}
Expand All @@ -103,6 +126,9 @@ export function readDelegatedGithubToken(tokenPath) {
if (/[\u0000-\u001f\u007f]/.test(token)) {
throw new Error("Maintainer token must not contain control characters.");
}
if (!/^[\x21-\x7e]+$/.test(token)) {
throw new Error("Maintainer token must contain visible ASCII characters only.");
}
return token;
} finally {
closeSync(descriptor);
Expand Down
177 changes: 158 additions & 19 deletions scripts/workflow-registry-disable-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evi

const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema";
const WORKFLOW_PATH_PREFIX = ".github/workflows/";
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
const LOWERCASE_SHA_40 = /^[0-9a-f]{40}$/;
const ISO_UTC_MILLISECOND = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const GITHUB_API_ROOT = "https://api.github.com";
Expand Down Expand Up @@ -73,7 +74,7 @@ function validWorkflowId(value) {
* Validate the narrow repository workflow-path language accepted for mutation.
*
* Paths must name one YAML file directly below `.github/workflows/`; nested,
* traversal, backslash, NUL, and non-YAML paths are deliberately refused.
* traversal, backslash, control-byte, and non-YAML paths are deliberately refused.
*
* @param {unknown} value proposed workflow path
* @returns {boolean} true only for a canonical mutable repository workflow path
Expand All @@ -84,7 +85,7 @@ function validWorkflowPath(value) {
|| !value.startsWith(WORKFLOW_PATH_PREFIX)
|| !/\.ya?ml$/.test(value)
|| value.includes("\\")
|| value.includes("\0")
|| CONTROL_CHARACTERS.test(value)
) {
return false;
}
Expand Down Expand Up @@ -145,6 +146,61 @@ function validPlanAuthority(plan) {
return AUTHENTIC_PLANS.has(plan);
}

/**
* Race one privileged GitHub operation against the exact request deadline so a
* transport or stream implementation cannot extend delegated authority by
* ignoring the supplied AbortSignal.
*
* @template T
* @param {Promise<T>} operation asynchronous transport or response-body operation
* @param {AbortSignal} signal end-to-end request deadline authority
* @returns {Promise<T>} operation result while request authority remains live
*/
async function awaitWithinRequestAuthority(operation, signal) {
let onAbort;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
signal.throwIfAborted();
return await Promise.race([operation, aborted]);
} finally {
signal.removeEventListener("abort", onAbort);
}
}

/**
* Cancel an untrusted transport/reader only as best-effort cleanup. Cleanup
* failure must never replace the bounded timeout or size policy that owns the
* rejection, and cleanup itself must never become another unbounded await.
*
* @param {{cancel?: (reason?: unknown) => unknown} | null | undefined} cancelable response body or reader
* @param {unknown} reason bounded cleanup reason
* @returns {void}
*/
function cancelBestEffort(cancelable, reason) {
if (typeof cancelable?.cancel !== "function") return;
try {
const cancellation = cancelable.cancel(reason);
if (cancellation && typeof cancellation.catch === "function") {
void cancellation.catch(() => undefined);
}
} catch {
// Cleanup cannot replace the owning policy failure.
}
}

/**
* Return whether the exact end-to-end request deadline has expired.
*
* @param {AbortSignal} signal request deadline authority
* @returns {boolean} true only for the configured timeout authority
*/
function requestTimedOut(signal) {
return signal.aborted && signal.reason?.name === "TimeoutError";
}

/**
* Create the least-authority GitHub REST transport required by the workflow
* disablement executor. The delegated token remains closure-private, and every
Expand Down Expand Up @@ -199,29 +255,46 @@ export function createGithubWorkflowDisablementTransport(input) {

/**
* Perform one bounded GitHub REST request with exact status-code semantics.
* The returned signal remains the same authority used by any response-body
* parsing so header receipt cannot silently reset the deadline budget.
*
* Raw network exceptions and response bodies are intentionally not propagated,
* preventing delegated credentials or untrusted remote data from entering logs.
*
* @param {string} path repository-relative GitHub REST endpoint path
* @param {string} method HTTP method required by the exact endpoint
* @param {number} expectedStatus sole accepted HTTP success status
* @returns {Promise<Response>} successful response matching the exact status
* @returns {Promise<{response: Response, signal: AbortSignal}>} response and unchanged deadline authority
*/
async function request(path, method, expectedStatus) {
const requestSignal = AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS);
let response;
let transportPromise;
try {
response = await fetchImpl(
transportPromise = Promise.resolve(fetchImpl(
`${GITHUB_API_ROOT}/repos/${EXPECTED_REPOSITORY}${path}`,
{
method,
headers,
cache: "no-store",
redirect: "error",
signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS),
signal: requestSignal,
},
);
));
response = await awaitWithinRequestAuthority(transportPromise, requestSignal);
} catch (error) {
if (requestTimedOut(requestSignal)) {
if (transportPromise) {
void transportPromise.then(
(lateResponse) => cancelBestEffort(
lateResponse?.body,
"GitHub workflow disablement transport request deadline exceeded",
),
() => undefined,
);
}
throw new Error("GitHub workflow disablement transport request timed out");
}
if (error?.name === "TimeoutError") {
throw new Error("GitHub workflow disablement transport request timed out");
}
Expand All @@ -239,7 +312,63 @@ export function createGithubWorkflowDisablementTransport(input) {
`GitHub workflow disablement transport expected HTTP ${expectedStatus} but received HTTP ${response.status}`,
);
}
return response;
return Object.freeze({ response, signal: requestSignal });
}

/**
* Read successful GitHub response bytes without permitting chunked or
* untrusted-length bodies to exceed the mutation transport's memory or
* end-to-end deadline authority.
*
* @param {Response} response successful GitHub response
* @param {AbortSignal} signal unchanged request deadline authority
* @returns {Promise<Uint8Array>} bounded response bytes
*/
async function readBoundedResponseBytes(response, signal) {
const reader = response.body?.getReader?.();
if (!reader) {
throw new Error(
"GitHub workflow disablement transport response body is not stream-readable",
);
Comment thread
seonghobae marked this conversation as resolved.
Resolved
}

const chunks = [];
let totalBytes = 0;
while (true) {
let read;
try {
read = await awaitWithinRequestAuthority(reader.read(), signal);
} catch (error) {
if (signal.aborted) {
cancelBestEffort(
reader,
"GitHub workflow disablement transport request deadline exceeded",
);
}
throw error;
}
const { done, value } = read;
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_RESPONSE_BYTES) {
cancelBestEffort(
reader,
"GitHub workflow disablement transport response exceeds the bounded size limit",
);
throw new Error(
"GitHub workflow disablement transport response exceeds the bounded size limit",
);
}
chunks.push(value);
}

const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
return bytes;
}

/**
Expand All @@ -249,26 +378,36 @@ export function createGithubWorkflowDisablementTransport(input) {
* any remote field can become protected-main or workflow mutation evidence.
*
* @param {Response} response successful GitHub response
* @param {AbortSignal} signal unchanged request deadline authority
* @returns {Promise<unknown>} parsed JSON value
*/
async function parseResponseJson(response) {
async function parseResponseJson(response, signal) {
const contentType = response.headers.get("content-type");
const reviewedJsonContentType = /^[\t ]*application\/json[\t ]*(?:;[\t ]*charset[\t ]*=[\t ]*utf-8[\t ]*)?$/i;
if (!reviewedJsonContentType.test(String(contentType))) {
throw new Error("GitHub workflow disablement transport response did not declare JSON content");
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
}
Comment thread
seonghobae marked this conversation as resolved.
Resolved

const advertisedLength = Number(response.headers.get("content-length"));
if (Number.isFinite(advertisedLength) && advertisedLength > MAX_RESPONSE_BYTES) {
throw new Error(
"GitHub workflow disablement transport response exceeds the bounded size limit",
);
}

const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > MAX_RESPONSE_BYTES) {
throw new Error(
"GitHub workflow disablement transport response exceeds the bounded size limit",
);
let bytes;
try {
bytes = await readBoundedResponseBytes(response, signal);
} catch (error) {
if (requestTimedOut(signal)) {
throw new Error("GitHub workflow disablement transport request timed out");
}
throw error;
}

let text;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
} catch {
throw new Error("GitHub workflow disablement transport response contains invalid UTF-8");
}
Expand All @@ -295,8 +434,8 @@ export function createGithubWorkflowDisablementTransport(input) {
*/
async function revalidateDefaultBranch({ repository }) {
requireRepository(repository);
const response = await request("/branches/main", "GET", 200);
const body = await parseResponseJson(response);
const { response, signal } = await request("/branches/main", "GET", 200);
const body = await parseResponseJson(response, signal);
const sha = body?.commit?.sha;
if (!LOWERCASE_SHA_40.test(sha ?? "")) {
throw new Error(
Expand All @@ -315,8 +454,8 @@ export function createGithubWorkflowDisablementTransport(input) {
async function revalidateWorkflow({ repository, workflowId }) {
requireRepository(repository);
requireWorkflowId(workflowId);
const response = await request(`/actions/workflows/${workflowId}`, "GET", 200);
const body = await parseResponseJson(response);
const { response, signal } = await request(`/actions/workflows/${workflowId}`, "GET", 200);
const body = await parseResponseJson(response, signal);
if (body?.id !== workflowId) {
throw new Error("GitHub workflow disablement transport returned invalid workflow identity");
}
Expand Down Expand Up @@ -599,4 +738,4 @@ export async function executeWorkflowDisablement(input) {
final_state: "disabled_manually",
mutation: "disable",
});
}
}
Loading
Loading