fix(ci): move the flake-gate home out of PR-writable space; pin git metadata

Rounds 8-16 kept re-reporting one class (R8-1, R8-36, R12-2 and its
re-reports, R13-21, R15-1, R16-1) because every fix was a re-validation
of a path that could still be swapped underneath it. The premise, not
the consumers, was wrong: rename(2) and unlink(2) need write permission
on the PARENT directory, never on the entry, and $RUNNER_TEMP's top
level is uid-1000 mode-755 on this pool while the container's node is
uid 1000. A 0700 root:root home there could always be renamed away
wholesale and replaced — so each added check (ownership, mode, inode
anchor, run-id marker, opened-directory re-stat) only narrowed the
window, and the inter-step window before a `uses:` upload could not be
closed at all, because that step cannot run shell.

- The home moves to /flake-gate. `/` is root:root 755 in this container:
  node can neither create, unlink nor rename entries in it, so the
  entry — and everything reached through it — is outside PR-controlled
  reach by construction, with no window left to re-check. The upload
  ships that tree directly. No env override: $GITHUB_ENV is uid-1000
  writable, so a configurable home would be a PR-reachable channel, and
  the record step rm -rf's whatever the home names. The extracted-script
  harness relocates that one constant into its scratch tree instead, and
  the production value is pinned structurally.
- inv_tmp stays under RUNNER_TEMP by necessity (the build user cannot
  enter the root-only home) and gains the :? guard.
- .git is re-owned to root after prepare's workspace chown (R4-1/R4-2):
  with it node-owned, a lifecycle script could plant a smudge filter and
  have root's per-invocation reset execute it every round, or rewrite
  HEAD so the restore installs a tree of its choosing. Non-fatal, since
  the reset already runs git with GIT_* stripped, under a timeout,
  against an OID pinned before any PR code ran.
- The scripts/tests arm gets the same substring-collision labelling the
  generic arm already had, so a same-stem sibling collected by vitest's
  positional filter is named in the attribution (R8-31/R13-28 lineage).

Suites: helper 112/112, twin 134/134 — both run as a NON-ROOT user,
which is what CI does; as root the gate pins its own PATH and the
harness stubs are bypassed by design, so a root run reports false
failures.
This commit is contained in:
wenshao 2026-08-18 04:07:43 +08:00
parent 1afadec3aa
commit 387a843434
2 changed files with 210 additions and 38 deletions

View file

@ -1002,7 +1002,7 @@ describe('qwen-triage: flakiness gate (#9125)', () => {
// executes, so it is a changed test file exactly like M.
assert.match(
recordStep.run,
/^\s*git -c core\.quotePath=false diff -z --name-only --diff-filter=ACMRT 'HEAD\^1' HEAD \\\n\s*> "\$RUNNER_TEMP\/flake-gate\/files-all"$/m,
/^\s*git -c core\.quotePath=false diff -z --name-only --diff-filter=ACMRT 'HEAD\^1' HEAD \\\n\s*> "\$GATE_HOME\/files-all"$/m,
'the NUL diff must flow straight into its file — $( ) strips NUL bytes, a pipeline swallows the exit status',
);
assert.ok(
@ -1013,7 +1013,7 @@ describe('qwen-triage: flakiness gate (#9125)', () => {
);
assert.match(
recordStep.run,
/^\s*grep_status=0\n\s*grep -zE '[^']+' \\\n\s*"\$RUNNER_TEMP\/flake-gate\/files-all" \\\n\s*> "\$RUNNER_TEMP\/flake-gate\/files" \|\| grep_status=\$\?$/m,
/^\s*grep_status=0\n\s*grep -zE '[^']+' \\\n\s*"\$GATE_HOME\/files-all" \\\n\s*> "\$GATE_HOME\/files" \|\| grep_status=\$\?$/m,
'the grep must read the raw file and only a no-match may yield an empty list',
);
// Every gate working file must live in the root-only home, because
@ -1022,12 +1022,12 @@ describe('qwen-triage: flakiness gate (#9125)', () => {
// replaced with symlinks that root-side consumers follow.
assert.match(
recordStep.run,
/^\s*install -d -m 0700 -o root -g root "\$RUNNER_TEMP\/flake-gate"$/m,
/^\s*install -d -m 0700 -o root -g root "\$GATE_HOME"$/m,
'the record step must create the root-only home',
);
assert.match(
recordStep.run,
/^\s*rm -rf -- "\$\{RUNNER_TEMP:\?\}\/flake-gate"$/m,
/^\s*rm -rf -- "\$GATE_HOME"$/m,
'a plant left by an earlier run on the persistent pool must be removed first',
);
// Run-freshness marker: a stale-but-genuine home from an earlier
@ -1036,7 +1036,7 @@ describe('qwen-triage: flakiness gate (#9125)', () => {
// runs apart when the record step itself was skipped.
assert.match(
recordStep.run,
/^\s*printf '%s-%s' "\$\{GITHUB_RUN_ID:\?\}" "\$\{GITHUB_RUN_ATTEMPT:\?\}" > "\$RUNNER_TEMP\/flake-gate\/run-id"$/m,
/^\s*printf '%s-%s' "\$\{GITHUB_RUN_ID:\?\}" "\$\{GITHUB_RUN_ATTEMPT:\?\}" > "\$GATE_HOME\/run-id"$/m,
'the record step must stamp the run identity into the home it creates',
);
// Startup-channel scrub: BASH_FUNC_* imports are dropped by a
@ -1580,7 +1580,7 @@ describe('qwen-triage: flakiness gate (#9125)', () => {
const uploadStep = verifyJob.steps[uploadIdx];
assert.equal(
uploadStep.with.path,
'${{ runner.temp }}/flake-gate/upload/',
'/flake-gate/upload/',
'the artifact must ship the REBUILT tree, never the agent-era directory',
);
// R12-2 entrance 3: staging's anchoring expires at its exit, and the
@ -1618,7 +1618,7 @@ describe('qwen-triage: flakiness gate (#9125)', () => {
);
assert.match(
recheckStep.run,
/\/usr\/bin\/rm -rf -- "\$\{RUNNER_TEMP:\?\}\/flake-gate"/,
/\/usr\/bin\/rm -rf -- "\$GATE_DIR"/,
'a home that fails the re-check must be removed before the upload enumerates the path',
);
assert.match(
@ -1723,9 +1723,7 @@ describe('qwen-triage: flakiness gate (#9125)', () => {
// unconditionally, so a home that failed validation must be removed
// — a stale tree left in place ships a previous run's evidence
// under this run's artifact name.
const iStaleRemoval = sr.search(
/^\s*rm -rf -- "\$\{RUNNER_TEMP:\?\}\/flake-gate"$/m,
);
const iStaleRemoval = sr.search(/^\s*rm -rf -- "\$GATE_DIR"$/m);
// Round 11 (R8-36): the guard and the cd re-resolve verify-results;
// a kill-loop survivor owning the uid-1000 parent can swap the
// entry between the two — the opened directory must still BE the
@ -1939,6 +1937,101 @@ describe('qwen-triage: flakiness gate (#9125)', () => {
);
});
it('the gate home sits at the container root, not in the writable RUNNER_TEMP', () => {
// The whole TOCTOU class (R8-1, R8-36, R12-2 and their re-reports)
// rests on one property of the PARENT, not the entry: rename(2) and
// unlink(2) need write permission on the directory holding the entry.
// $RUNNER_TEMP's top level is uid-1000 writable and the container's
// node is uid 1000, so a 0700 root home there could always be
// renamed away wholesale — every added re-validation only narrowed
// the window. `/` is root:root 755, so entries in it are outside
// PR-controlled reach with no window to re-check.
for (const [label, step] of [
['record', recordStep],
['gate', flakeStep],
[
'staging',
verifyJob.steps.find(
(x) => x.name === 'Stage flakiness gate log for upload',
),
],
[
'upload re-check',
verifyJob.steps.find((x) => x.id === 'flake-upload-check'),
],
]) {
assert.doesNotMatch(
step.run,
/\$\{?RUNNER_TEMP:?\??\}?\/flake-gate/,
`${label} must not place the gate home under RUNNER_TEMP`,
);
}
assert.match(
recordStep.run,
/^\s*GATE_HOME=\/flake-gate$/m,
'the record step must create the home at the container root',
);
for (const [label, step] of [
['gate', flakeStep],
[
'staging',
verifyJob.steps.find(
(x) => x.name === 'Stage flakiness gate log for upload',
),
],
[
'upload re-check',
verifyJob.steps.find((x) => x.id === 'flake-upload-check'),
],
]) {
assert.match(
step.run,
/^\s*GATE_DIR=\/flake-gate$/m,
`${label} must resolve the home to the container-root constant`,
);
}
const uploadStep = verifyJob.steps.find(
(x) => x.name === 'Upload verify results',
);
assert.equal(
uploadStep.with.path,
'/flake-gate/upload/',
'the artifact must ship the tree that lives outside PR-writable space',
);
// No env knob: $GITHUB_ENV is uid-1000 writable, so an overridable
// home would be a PR-reachable channel — and the record step rm -rf's
// whatever the home names.
for (const step of verifyJob.steps) {
assert.doesNotMatch(
String(step.run ?? ''),
/FLAKE_GATE_HOME/,
'the gate home must not be overridable through the environment',
);
}
});
it('git metadata stays root-owned across the build so the reset cannot be steered', () => {
// With .git node-owned, a lifecycle script could plant a smudge
// filter plus info/attributes and have ROOT's per-invocation reset
// execute it every round, or rewrite HEAD so the "restore" installs
// a tree of its choosing (R4-1/R4-2).
assert.match(
prepareStep.run,
/^\s*chown -R root:root "\$GITHUB_WORKSPACE\/\.git"/m,
'prepare must re-own .git to root after chowning the workspace',
);
const wsChown = prepareStep.run.indexOf(
'chown -R node:node "$GITHUB_WORKSPACE"',
);
const gitChown = prepareStep.run.indexOf(
'chown -R root:root "$GITHUB_WORKSPACE/.git"',
);
assert.ok(
wsChown !== -1 && gitChown > wsChown,
'the .git re-own must come after the workspace chown that would otherwise hand it over',
);
});
it('the verify job timeout still covers agent + prepare + gate', () => {
// agent 120m + install/build 15m + gate ~40m (the 15m round budget is
// checked BEFORE each reset, so the last invocation drags its reset
@ -1961,7 +2054,18 @@ describe('qwen-triage: flakiness gate — behavioral, under the production wrapp
// does NOT clear that inherited -e. That exact blind spot shipped the
// round-1 blocker — the first failing test invocation killed the step —
// so every scenario here runs under the wrapper, not under a bare bash.
const flakeRun = verifyJob.steps.find((s) => s.id === 'flake').run;
const flakeRunVerbatim = verifyJob.steps.find((s) => s.id === 'flake').run;
// The gate's home is a hard-coded container-root constant on purpose: an
// env-overridable home would be a PR-reachable channel ($GITHUB_ENV is
// uid-1000 writable and the record step rm -rf's whatever the home names).
// The harness therefore relocates that one constant into its scratch tree
// — a fixture substitution, not a production knob. The structural suite
// pins the production value separately.
const PROD_GATE_HOME = '/flake-gate';
assert.ok(
flakeRunVerbatim.includes(`GATE_DIR=${PROD_GATE_HOME}`),
'the gate must define its home as the container-root constant',
);
const publishRun = doc.jobs['publish-verify'].steps.find(
(s) => s.name === 'Post verification report comment',
).run;
@ -2154,7 +2258,13 @@ describe('qwen-triage: flakiness gate — behavioral, under the production wrapp
chmodSync(join(bin, name), 0o755);
}
const gateFile = join(root, 'gate.sh');
writeFileSync(gateFile, flakeRun);
writeFileSync(
gateFile,
flakeRunVerbatim.replaceAll(
`GATE_DIR=${PROD_GATE_HOME}`,
`GATE_DIR=${gateDir}`,
),
);
const out = join(rt, 'github-output');
writeFileSync(out, '');
writeFileSync(join(rt, 'github-summary'), '');
@ -3396,7 +3506,17 @@ describe('qwen-triage: flakiness gate staging/upload — behavioral, under the p
const runStaging = (rt, bin) => {
const scriptFile = join(rt, 'staging.sh');
writeFileSync(scriptFile, stageStep.run);
// Same fixture relocation as the gate harness: the home is a
// hard-coded container-root constant in production (an env knob there
// would be PR-reachable), so the suite moves that one constant into
// its scratch tree and pins the production value structurally.
writeFileSync(
scriptFile,
stageStep.run.replaceAll(
'GATE_DIR=/flake-gate',
`GATE_DIR=${join(rt, 'flake-gate')}`,
),
);
const out = join(rt, 'github-output');
writeFileSync(out, '');
return spawnSync(
@ -3492,7 +3612,18 @@ describe('qwen-triage: flakiness gate staging/upload — behavioral, under the p
writeFileSync(out, '');
const res = spawnSync(
'bash',
['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', recheckStep.run],
[
'--noprofile',
'--norc',
'-e',
'-o',
'pipefail',
'-c',
recheckStep.run.replaceAll(
'GATE_DIR=/flake-gate',
`GATE_DIR=${join(rt, 'flake-gate')}`,
),
],
{
env: {
...process.env,

View file

@ -2859,13 +2859,28 @@ jobs:
# swapped for /proc/<pid>/environ (NUL-framed KEY=VALUE is
# exactly the gate's intake framing, so ACTIONS_RUNTIME_TOKEN
# lands verbatim in the published log), the round output or the
# staged log swapped for any root-readable file. 0700 root:root
# removes the capability itself — a directory node cannot enter
# is one whose entries it can neither create, unlink, nor
# rename. rm -rf first: the entry may already be a plant from an
# earlier run on this persistent pool.
rm -rf -- "${RUNNER_TEMP:?}/flake-gate"
install -d -m 0700 -o root -g root "$RUNNER_TEMP/flake-gate"
# staged log swapped for any root-readable file.
#
# The home lives at the CONTAINER ROOT, not under $RUNNER_TEMP.
# That is the whole defence, and it is a property of the PARENT:
# rename(2) and unlink(2) need write permission on the directory
# holding the entry, never on the entry itself — so a 0700
# root:root home inside the uid-1000-writable $RUNNER_TEMP could
# always be renamed away wholesale and replaced with a
# node-owned lookalike, no matter how the gate validated it
# afterwards. Every re-validation we added there (ownership,
# mode, inode anchor, run-id marker) only narrowed the window;
# each one left the next TOCTOU gap, including the inter-step
# window before a `uses:` upload that cannot run shell at all.
# `/` is root:root 755 in this container: node can neither
# create, unlink, nor rename entries in it, so /flake-gate's
# entry — and therefore everything reached through it — is
# outside PR-controlled reach by construction, with no window to
# re-check. rm -rf first: the container is fresh per job, but a
# retried job on a warm image must not adopt an earlier tree.
GATE_HOME=/flake-gate
rm -rf -- "$GATE_HOME"
install -d -m 0700 -o root -g root "$GATE_HOME"
# Run-freshness marker: staging re-validates it before trusting
# the home. A stale-but-genuine home left by an earlier run on
# the persistent pool passes every ownership/mode/shape check —
@ -2873,7 +2888,7 @@ jobs:
# can never restage a previous run's evidence under this run's
# artifact name when the record step itself was skipped (a
# cancel between steps.pr and record still runs staging).
printf '%s-%s' "${GITHUB_RUN_ID:?}" "${GITHUB_RUN_ATTEMPT:?}" > "$RUNNER_TEMP/flake-gate/run-id"
printf '%s-%s' "${GITHUB_RUN_ID:?}" "${GITHUB_RUN_ATTEMPT:?}" > "$GATE_HOME/run-id"
# Two statements, not a pipeline: `git diff | grep || true` would
# swallow a git failure as "no changed test files", silently
# narrowing the gate to n/a. A git failure here is pre-build
@ -2891,7 +2906,7 @@ jobs:
# the runner executes, so it is a changed test file exactly
# like M — excluding it silently drops the file from the gate.
git -c core.quotePath=false diff -z --name-only --diff-filter=ACMRT 'HEAD^1' HEAD \
> "$RUNNER_TEMP/flake-gate/files-all"
> "$GATE_HOME/files-all"
# .mts/.cts included: vitest's default include set collects them.
# Only a no-match (status 1) may yield an empty list: a grep
# error (status 2, e.g. ENOSPC opening the output) is
@ -2899,14 +2914,14 @@ jobs:
# would narrow the gate to zero files and starve it into n/a.
grep_status=0
grep -zE '\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$' \
"$RUNNER_TEMP/flake-gate/files-all" \
> "$RUNNER_TEMP/flake-gate/files" || grep_status=$?
"$GATE_HOME/files-all" \
> "$GATE_HOME/files" || grep_status=$?
if [ "$grep_status" -gt 1 ]; then
echo "flake-gate intake: grep failed with status ${grep_status}" >&2
exit 1
fi
rm -f "$RUNNER_TEMP/flake-gate/files-all"
echo "Recorded $(tr -cd '\0' < "$RUNNER_TEMP/flake-gate/files" | wc -c) changed test file(s) for the flakiness gate."
rm -f "$GATE_HOME/files-all"
echo "Recorded $(tr -cd '\0' < "$GATE_HOME/files" | wc -c) changed test file(s) for the flakiness gate."
- name: 'Clear stale npm cache'
if: "steps.pr.outputs.decision == 'run'"
@ -2944,6 +2959,24 @@ jobs:
rm -f "$RUNNER_TEMP/verify-chromium-path"
mkdir -p "$RUNNER_TEMP/verify-results"
chown -R node:node "$GITHUB_WORKSPACE"
# ...but NOT .git. The gate's per-invocation reset restores the
# tree with `git checkout HEAD -- .`, and git reads its config,
# hooks and attributes from this directory as it does so: with
# .git node-owned, a lifecycle script could plant a
# `filter.<x>.smudge` command plus `info/attributes` and have
# ROOT's reset execute it on every round, or rewrite HEAD so the
# "restore" installs a tree of its choosing. Read access is all
# the build needs (git describe and friends); write access is
# what turns the reset into an execution and content primitive.
# Defence in depth, not the only line: the reset already runs
# git with the GIT_* env stripped, under a timeout, against an
# OID pinned before any PR code ran. Non-fatal so a fixture or
# a non-root harness cannot abort the build over it.
if [ -d "$GITHUB_WORKSPACE/.git" ]; then
chown -R root:root "$GITHUB_WORKSPACE/.git" 2>/dev/null ||
echo "::warning::could not re-own .git to root; the gate reset falls back to its env/timeout/pinned-OID defences"
chmod -R go-w "$GITHUB_WORKSPACE/.git" 2>/dev/null || true
fi
# Make the npm cache readable+writable by the build user so
# `npm ci --prefer-offline --cache …` can use it without
# touching the Actions cache API (whose credentials were
@ -3178,7 +3211,7 @@ jobs:
# it: a plant (symlink, node-owned dir, loosened mode) means the
# integrity premise never held, and the gate must degrade to the
# fixed error verdict rather than read attacker-chosen bytes.
GATE_DIR="${RUNNER_TEMP:?}/flake-gate"
GATE_DIR=/flake-gate
# -O is the load-bearing test: "owned by the EFFECTIVE user" is
# root in production, so a directory a PR planted (owned by node)
# fails it, while the same code stays runnable under a harness.
@ -3348,7 +3381,11 @@ jobs:
esac
case "$f" in
scripts/tests/*.test.js|scripts/tests/*.test.ts)
group_labels+=("$f")
# Same substring-collision honesty as the generic arm: one
# positional filter collects every path containing it.
label="$f"
[ -f "${f}x" ] && label="$f + ${f}x"
group_labels+=("$label")
group_dirs+=('.')
group_cmds+=("npx --no-install vitest run --config ./scripts/tests/vitest.config.ts $(printf '%q' "./$f")")
;;
@ -3546,7 +3583,11 @@ jobs:
# Fresh per-invocation HOME and temp/cache dirs: samples must
# not share dotfile/XDG/cache state or caches any more than
# they share the tree or processes.
inv_tmp="$RUNNER_TEMP/flake-inv-tmp"
# Node-writable by necessity (the build user cannot enter
# the root-only home), so it stays under RUNNER_TEMP — and
# :? keeps a missing RUNNER_TEMP from silently relocating
# it to the container root.
inv_tmp="${RUNNER_TEMP:?}/flake-inv-tmp"
rm -rf "$inv_tmp"
mkdir -p "$inv_tmp"
# -h: never dereference — a planted symlink at this fixed
@ -4276,7 +4317,7 @@ jobs:
echo "verify verdict: $VERDICT agent: ${AGENT_VERDICT:-none} (exit $EXIT_CODE)" >> "$GITHUB_STEP_SUMMARY"
# The gate log's authoritative copy lives root-owned inside the 0700
# root-only home ($RUNNER_TEMP/flake-gate/log), which no PR-controlled
# root-only home (/flake-gate/log), which no PR-controlled
# process can enter. This step runs AFTER the agent exits, from an
# always() root step, and assembles the upload tree there too: an
# early agent-step abort cannot lose the log, and agent-era PR code
@ -4369,10 +4410,10 @@ jobs:
# in place always left the ENTRY itself renameable, so a kill-race
# survivor could swap the whole hardened tree for a symlink farm
# that upload-artifact (which follows links) would publish. The
# upload now reads from $RUNNER_TEMP/flake-gate/upload, inside the
# upload now reads from /flake-gate/upload, inside the
# 0700 root-only home: a directory node cannot enter is one whose
# entries it can neither create, unlink, nor rename.
GATE_DIR="${RUNNER_TEMP:?}/flake-gate"
GATE_DIR=/flake-gate
UPLOAD_DIR="$GATE_DIR/upload"
# The run-id conjunct is the RUN-identity check: ownership,
# mode and shape all pass on a stale-but-genuine home an
@ -4474,10 +4515,10 @@ jobs:
# this run's artifact name. rm -rf removes a symlink
# operand itself, never following it.
echo "::warning::flake-gate home missing, invalid, or swapped mid-staging; skipping the trusted upload rebuild."
rm -rf -- "${RUNNER_TEMP:?}/flake-gate"
rm -rf -- "$GATE_DIR"
fi
# The always() upload below re-resolves $RUNNER_TEMP/flake-gate in a
# The always() upload below re-resolves /flake-gate in a
# fresh step — staging's anchoring expires at its exit, so a swap
# between the two hands the upload a plant. Re-validate the entry's
# identity immediately before the upload and remove it on any
@ -4502,7 +4543,7 @@ jobs:
export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
fi
upload_ok=false
GATE_DIR="${RUNNER_TEMP:?}/flake-gate"
GATE_DIR=/flake-gate
if [[ ! -L $GATE_DIR ]] && [[ -d $GATE_DIR ]] && [[ -O $GATE_DIR ]] &&
[[ $(/usr/bin/stat -c '%a' "$GATE_DIR" 2>/dev/null) == '700' ]] &&
[[ $(/usr/bin/cat "$GATE_DIR/run-id" 2>/dev/null) == "${GITHUB_RUN_ID:?}-${GITHUB_RUN_ATTEMPT:?}" ]]; then
@ -4518,7 +4559,7 @@ jobs:
# Whatever occupies the entry now is not this run's
# validated home — remove it so the upload cannot
# enumerate a plant.
/usr/bin/rm -rf -- "${RUNNER_TEMP:?}/flake-gate"
/usr/bin/rm -rf -- "$GATE_DIR"
fi
echo "upload_ok=$upload_ok" >> "$GITHUB_OUTPUT"
@ -4533,7 +4574,7 @@ jobs:
# The rebuilt, root-only tree — not the agent-era directory the
# build user owned. Same inner layout, so the publisher's paths
# are unchanged.
path: '${{ runner.temp }}/flake-gate/upload/'
path: '/flake-gate/upload/'
retention-days: 7
- name: 'Clean up runner workspace'