From 5b182314233e30fcaf41bb69649428e29e9391d3 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Thu, 20 Aug 2026 12:09:49 +0900
Subject: [PATCH 1/3] fix: make the release sync guard actually catch the bug
it guards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The guard I pushed straight to main yesterday matched line shapes with
regexes, and an adversarial review found three inputs that reproduce the
exact exit 126 outage while the audit reported clean: a blank line between
the list and the orphaned path, a comment there instead, and a backslash
followed by a space (an escaped space in bash, not a continuation). All
three were confirmed by running bash.
Replace the shape matching with bash's own continuation rule — a line
continues only on an odd number of trailing backslashes at end of line —
then join logical commands and reject any whose first word is a repository
path that is not executable. That catches the orphan regardless of what
separates it, and still allows './scripts/sync-versions.sh'. Tests cover
each reproduction.
Also resolve paths from the module instead of the caller's cwd, the way
sync-release-generated.sh already does, and fix the main-module check,
which compared a percent-encoded URL against a raw argv path and silently
exited 0 on any checkout path containing a space.
The release note contradicted itself: the summary claimed the errors stop
while its own integration note said the key only works on Godot 4.8.
Co-Authored-By: Claude Opus 5
---
.../docs/src/pages/docs/updates/releases.tsx | 4 +-
scripts/audit-release-sync-script.mjs | 132 ++++++++++++------
scripts/audit-release-sync-script.test.mjs | 75 +++++++---
3 files changed, 150 insertions(+), 61 deletions(-)
diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx
index ca6275e91..d425e2991 100644
--- a/packages/docs/src/pages/docs/updates/releases.tsx
+++ b/packages/docs/src/pages/docs/updates/releases.tsx
@@ -276,8 +276,8 @@ function Releases() {
}}
>
godot-iap 3.3.3 declares the Apple-only GDExtension with the key
- Godot actually reads, so editors on Windows and Linux stop logging a
- missing-library error on every project scan.
+ Godot actually reads, so Godot 4.8 editors on Windows and Linux skip
+ it instead of logging a missing-library error on every project scan.
Framework libraries
diff --git a/scripts/audit-release-sync-script.mjs b/scripts/audit-release-sync-script.mjs
index 9967c29ca..419f21236 100644
--- a/scripts/audit-release-sync-script.mjs
+++ b/scripts/audit-release-sync-script.mjs
@@ -1,65 +1,113 @@
#!/usr/bin/env node
-// The release lanes all call sync-release-generated.sh, and nothing else ever
-// runs it — a dropped line continuation in its `git add` shipped once and only
-// surfaced mid-release (exit 126, the next path ran as a command). Neither
-// `bash -n` nor shellcheck flags that, so check the shape here.
+// Every release lane calls sync-release-generated.sh and nothing else runs it,
+// so a dropped line continuation in its `git add` shipped once and only
+// surfaced mid-release: bash ended the command early and ran the next path as
+// a command (exit 126, "is a directory"). Neither `bash -n` nor shellcheck
+// flags that — an orphaned path is a syntactically valid command — so this
+// audit reproduces bash's own continuation rules and then asserts that no
+// logical command starts with a repository path.
-import { readFileSync, existsSync } from 'node:fs';
+import { readFileSync, existsSync, statSync, accessSync, constants } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SCRIPT = 'scripts/sync-release-generated.sh';
+// A line continues only when it ends in an odd number of backslashes at the
+// very end of the line. `foo \ ` (backslash, space) is an escaped space, so
+// bash ends the command there — the exact trap the previous check missed.
+function continuesLine(line) {
+ const match = /(\\+)$/u.exec(line);
+ return match ? match[1].length % 2 === 1 : false;
+}
+
+export function toLogicalLines(source) {
+ const logical = [];
+ let buffer = null;
+
+ source.split('\n').forEach((raw, index) => {
+ const isContinuation = buffer !== null;
+ const text = isContinuation ? raw : raw.replace(/^\s+/u, '');
+
+ if (!isContinuation && (text === '' || text.startsWith('#'))) return;
+
+ if (continuesLine(raw)) {
+ const joined = text.replace(/\\$/u, '').trim();
+ buffer = buffer
+ ? { ...buffer, text: `${buffer.text} ${joined}` }
+ : { line: index + 1, text: joined };
+ return;
+ }
+
+ logical.push(
+ buffer
+ ? { line: buffer.line, text: `${buffer.text} ${text.trim()}` }
+ : { line: index + 1, text },
+ );
+ buffer = null;
+ });
+
+ if (buffer) logical.push(buffer);
+ return logical;
+}
+
+function isRunnable(target) {
+ if (statSync(target).isDirectory()) return false;
+ try {
+ accessSync(target, constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
export function auditGitAddBlocks(source) {
const failures = [];
- const lines = source.split('\n');
+ const logical = toLogicalLines(source);
+ let sawGitAdd = false;
- lines.forEach((line, index) => {
- if (!/^\s*git add\s*\\\s*$/.test(line)) return;
+ logical.forEach(({ line, text }) => {
+ const first = text.trim().split(/\s+/u)[0] ?? '';
- for (let i = index + 1; i < lines.length; i += 1) {
- const raw = lines[i];
- const continues = /\\\s*$/.test(raw);
- const path = raw.replace(/\\\s*$/, '').trim();
-
- if (path === '') {
- failures.push(`${SCRIPT}:${i + 1}: blank line inside the git add list`);
- break;
+ // Running a path is fine when it is an executable script; a directory or
+ // a plain file can only have become a command by accident.
+ if (first.includes('/') && !first.startsWith('$') && !first.startsWith('"')) {
+ const target = join(REPO_ROOT, first.replace(/^\.\//u, ''));
+ if (existsSync(target) && !isRunnable(target)) {
+ failures.push(
+ `${SCRIPT}:${line}: "${first}" runs as a command; a line above it lost its "\\"`,
+ );
}
+ }
- failures.push(...auditPath(path, i + 1));
+ if (!/^git add\b/u.test(text.trim())) return;
+ sawGitAdd = true;
- if (!continues) {
- // Last entry. A following indented path means a lost continuation.
- const next = lines[i + 1] ?? '';
- if (/^\s+\S/.test(next) && !/^\s*(#|fi\b|done\b|esac\b|\})/.test(next)) {
- failures.push(
- `${SCRIPT}:${i + 1}: git add list ends here but line ${i + 2} ` +
- `("${next.trim()}") is indented — a lost "\\" would run it as a command`,
- );
+ text
+ .trim()
+ .replace(/^git add\s*/u, '')
+ .split(/\s+/u)
+ .filter((token) => token && !token.startsWith('-'))
+ .forEach((token) => {
+ if (token.includes('$') || token.includes('*')) return;
+ if (!existsSync(join(REPO_ROOT, token))) {
+ failures.push(`${SCRIPT}:${line}: staged path does not exist: ${token}`);
}
- break;
- }
- }
+ });
});
- if (!/^\s*git add\s*\\\s*$/m.test(source)) {
- failures.push(`${SCRIPT}: no multi-line git add block found`);
- }
-
+ if (!sawGitAdd) failures.push(`${SCRIPT}: no git add command found`);
return failures;
}
-function auditPath(path, line) {
- if (path.includes('$') || path.includes('*')) return [];
- return existsSync(path)
- ? []
- : [`${SCRIPT}:${line}: staged path does not exist: ${path}`];
-}
-
-if (import.meta.url === `file://${process.argv[1]}`) {
- const failures = auditGitAddBlocks(readFileSync(SCRIPT, 'utf8'));
+if (fileURLToPath(import.meta.url) === process.argv[1]) {
+ const failures = auditGitAddBlocks(
+ readFileSync(join(REPO_ROOT, SCRIPT), 'utf8'),
+ );
if (failures.length) {
console.error('Release sync script audit failed:');
- failures.forEach((f) => console.error(`- ${f}`));
+ failures.forEach((failure) => console.error(`- ${failure}`));
process.exit(1);
}
console.log('Release sync script audit passed.');
diff --git a/scripts/audit-release-sync-script.test.mjs b/scripts/audit-release-sync-script.test.mjs
index 844eec603..38d20f543 100644
--- a/scripts/audit-release-sync-script.test.mjs
+++ b/scripts/audit-release-sync-script.test.mjs
@@ -1,44 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
-import { auditGitAddBlocks } from './audit-release-sync-script.mjs';
+import {
+ auditGitAddBlocks,
+ toLogicalLines,
+} from './audit-release-sync-script.mjs';
-test('catches the dropped line continuation that broke a release', () => {
- const broken = [
- 'git add \\',
- ' packages/docs/public/llms.txt \\',
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+const HEAD = ['git add \\', ' packages/docs/public/llms.txt \\'];
+
+function orphaned(...between) {
+ return [
+ ...HEAD,
' knowledge/_agent-context/context.md',
+ ...between,
' knowledge/_claude-context',
].join('\n');
+}
- const failures = auditGitAddBlocks(broken);
+test('catches the dropped continuation that broke a release', () => {
+ const failures = auditGitAddBlocks(orphaned());
assert.equal(failures.length, 1);
- assert.match(failures[0], /would run it as a command/u);
+ assert.match(failures[0], /runs as a command/u);
+});
+
+test('catches an orphaned path separated by a blank line', () => {
+ assert.match(auditGitAddBlocks(orphaned(''))[0], /runs as a command/u);
+});
+
+test('catches an orphaned path separated by a comment', () => {
+ assert.match(
+ auditGitAddBlocks(orphaned(' # agent context symlink'))[0],
+ /runs as a command/u,
+ );
+});
+
+test('treats a backslash followed by a space as the end of the command', () => {
+ // `\ ` is an escaped space in bash, not a continuation.
+ const source = ['git add \\ ', ' knowledge/_claude-context'].join('\n');
+ assert.ok(auditGitAddBlocks(source).length > 0);
});
-test('accepts the same list once the continuation is restored', () => {
- const fixed = [
- 'git add \\',
- ' packages/docs/public/llms.txt \\',
+test('accepts the list once every continuation is intact', () => {
+ const source = [
+ ...HEAD,
' knowledge/_agent-context/context.md \\',
' knowledge/_claude-context',
].join('\n');
+ assert.deepEqual(auditGitAddBlocks(source), []);
+});
- assert.deepEqual(auditGitAddBlocks(fixed), []);
+test('allows running an executable script by path', () => {
+ assert.deepEqual(
+ auditGitAddBlocks(['./scripts/sync-versions.sh', 'git add .'].join('\n')),
+ [],
+ );
});
test('reports a staged path that no longer exists', () => {
- const stale = ['git add \\', ' knowledge/_removed-context/context.md'].join(
+ const source = ['git add \\', ' knowledge/_removed-context/context.md'].join(
'\n',
);
+ assert.match(auditGitAddBlocks(source)[0], /staged path does not exist/u);
+});
- const failures = auditGitAddBlocks(stale);
- assert.equal(failures.length, 1);
- assert.match(failures[0], /staged path does not exist/u);
+test('joins continued lines into one logical command', () => {
+ const logical = toLogicalLines(['git add \\', ' a \\', ' b'].join('\n'));
+ assert.deepEqual(
+ logical.map((entry) => entry.text),
+ ['git add a b'],
+ );
});
test('the committed script passes its own audit', () => {
- const source = readFileSync('scripts/sync-release-generated.sh', 'utf8');
+ const source = readFileSync(
+ join(REPO_ROOT, 'scripts/sync-release-generated.sh'),
+ 'utf8',
+ );
assert.deepEqual(auditGitAddBlocks(source), []);
});
From 7616c993a2faa7716440cc3da332bf97d93f63a0 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Thu, 20 Aug 2026 12:31:27 +0900
Subject: [PATCH 2/3] fix: keep continued tokens adjacent when joining, as bash
does
bash deletes the backslash-newline pair outright, so `llms.tx\t`
is the single token llms.txt. The joiner inserted a space there, which
would have split one staged path into two nonexistent ones and failed the
audit closed. Regression test included.
Co-Authored-By: Claude Opus 5
---
scripts/audit-release-sync-script.mjs | 58 +++++++++------
scripts/audit-release-sync-script.test.mjs | 83 +++++++++++++---------
2 files changed, 84 insertions(+), 57 deletions(-)
diff --git a/scripts/audit-release-sync-script.mjs b/scripts/audit-release-sync-script.mjs
index 419f21236..6ba637fa9 100644
--- a/scripts/audit-release-sync-script.mjs
+++ b/scripts/audit-release-sync-script.mjs
@@ -7,12 +7,18 @@
// audit reproduces bash's own continuation rules and then asserts that no
// logical command starts with a repository path.
-import { readFileSync, existsSync, statSync, accessSync, constants } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
-const SCRIPT = 'scripts/sync-release-generated.sh';
+import {
+ readFileSync,
+ existsSync,
+ statSync,
+ accessSync,
+ constants,
+} from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
+const SCRIPT = "scripts/sync-release-generated.sh";
// A line continues only when it ends in an odd number of backslashes at the
// very end of the line. `foo \ ` (backslash, space) is an escaped space, so
@@ -26,23 +32,25 @@ export function toLogicalLines(source) {
const logical = [];
let buffer = null;
- source.split('\n').forEach((raw, index) => {
+ source.split("\n").forEach((raw, index) => {
const isContinuation = buffer !== null;
- const text = isContinuation ? raw : raw.replace(/^\s+/u, '');
+ const text = isContinuation ? raw : raw.replace(/^\s+/u, "");
- if (!isContinuation && (text === '' || text.startsWith('#'))) return;
+ if (!isContinuation && (text === "" || text.startsWith("#"))) return;
+ // Bash deletes the backslash-newline pair outright, so `a\b` is
+ // the single token `ab`. Concatenate rather than joining with a space.
if (continuesLine(raw)) {
- const joined = text.replace(/\\$/u, '').trim();
+ const joined = text.replace(/\\$/u, "");
buffer = buffer
- ? { ...buffer, text: `${buffer.text} ${joined}` }
+ ? { ...buffer, text: buffer.text + joined }
: { line: index + 1, text: joined };
return;
}
logical.push(
buffer
- ? { line: buffer.line, text: `${buffer.text} ${text.trim()}` }
+ ? { line: buffer.line, text: buffer.text + text }
: { line: index + 1, text },
);
buffer = null;
@@ -68,12 +76,16 @@ export function auditGitAddBlocks(source) {
let sawGitAdd = false;
logical.forEach(({ line, text }) => {
- const first = text.trim().split(/\s+/u)[0] ?? '';
+ const first = text.trim().split(/\s+/u)[0] ?? "";
// Running a path is fine when it is an executable script; a directory or
// a plain file can only have become a command by accident.
- if (first.includes('/') && !first.startsWith('$') && !first.startsWith('"')) {
- const target = join(REPO_ROOT, first.replace(/^\.\//u, ''));
+ if (
+ first.includes("/") &&
+ !first.startsWith("$") &&
+ !first.startsWith('"')
+ ) {
+ const target = join(REPO_ROOT, first.replace(/^\.\//u, ""));
if (existsSync(target) && !isRunnable(target)) {
failures.push(
`${SCRIPT}:${line}: "${first}" runs as a command; a line above it lost its "\\"`,
@@ -86,13 +98,15 @@ export function auditGitAddBlocks(source) {
text
.trim()
- .replace(/^git add\s*/u, '')
+ .replace(/^git add\s*/u, "")
.split(/\s+/u)
- .filter((token) => token && !token.startsWith('-'))
+ .filter((token) => token && !token.startsWith("-"))
.forEach((token) => {
- if (token.includes('$') || token.includes('*')) return;
+ if (token.includes("$") || token.includes("*")) return;
if (!existsSync(join(REPO_ROOT, token))) {
- failures.push(`${SCRIPT}:${line}: staged path does not exist: ${token}`);
+ failures.push(
+ `${SCRIPT}:${line}: staged path does not exist: ${token}`,
+ );
}
});
});
@@ -103,12 +117,12 @@ export function auditGitAddBlocks(source) {
if (fileURLToPath(import.meta.url) === process.argv[1]) {
const failures = auditGitAddBlocks(
- readFileSync(join(REPO_ROOT, SCRIPT), 'utf8'),
+ readFileSync(join(REPO_ROOT, SCRIPT), "utf8"),
);
if (failures.length) {
- console.error('Release sync script audit failed:');
+ console.error("Release sync script audit failed:");
failures.forEach((failure) => console.error(`- ${failure}`));
process.exit(1);
}
- console.log('Release sync script audit passed.');
+ console.log("Release sync script audit passed.");
}
diff --git a/scripts/audit-release-sync-script.test.mjs b/scripts/audit-release-sync-script.test.mjs
index 38d20f543..811c66f77 100644
--- a/scripts/audit-release-sync-script.test.mjs
+++ b/scripts/audit-release-sync-script.test.mjs
@@ -1,85 +1,98 @@
-import { test } from 'node:test';
-import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
import {
auditGitAddBlocks,
toLogicalLines,
-} from './audit-release-sync-script.mjs';
+} from "./audit-release-sync-script.mjs";
-const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
-const HEAD = ['git add \\', ' packages/docs/public/llms.txt \\'];
+const HEAD = ["git add \\", " packages/docs/public/llms.txt \\"];
function orphaned(...between) {
return [
...HEAD,
- ' knowledge/_agent-context/context.md',
+ " knowledge/_agent-context/context.md",
...between,
- ' knowledge/_claude-context',
- ].join('\n');
+ " knowledge/_claude-context",
+ ].join("\n");
}
-test('catches the dropped continuation that broke a release', () => {
+test("catches the dropped continuation that broke a release", () => {
const failures = auditGitAddBlocks(orphaned());
assert.equal(failures.length, 1);
assert.match(failures[0], /runs as a command/u);
});
-test('catches an orphaned path separated by a blank line', () => {
- assert.match(auditGitAddBlocks(orphaned(''))[0], /runs as a command/u);
+test("catches an orphaned path separated by a blank line", () => {
+ assert.match(auditGitAddBlocks(orphaned(""))[0], /runs as a command/u);
});
-test('catches an orphaned path separated by a comment', () => {
+test("catches an orphaned path separated by a comment", () => {
assert.match(
- auditGitAddBlocks(orphaned(' # agent context symlink'))[0],
+ auditGitAddBlocks(orphaned(" # agent context symlink"))[0],
/runs as a command/u,
);
});
-test('treats a backslash followed by a space as the end of the command', () => {
+test("treats a backslash followed by a space as the end of the command", () => {
// `\ ` is an escaped space in bash, not a continuation.
- const source = ['git add \\ ', ' knowledge/_claude-context'].join('\n');
+ const source = ["git add \\ ", " knowledge/_claude-context"].join("\n");
assert.ok(auditGitAddBlocks(source).length > 0);
});
-test('accepts the list once every continuation is intact', () => {
+test("accepts the list once every continuation is intact", () => {
const source = [
...HEAD,
- ' knowledge/_agent-context/context.md \\',
- ' knowledge/_claude-context',
- ].join('\n');
+ " knowledge/_agent-context/context.md \\",
+ " knowledge/_claude-context",
+ ].join("\n");
assert.deepEqual(auditGitAddBlocks(source), []);
});
-test('allows running an executable script by path', () => {
+test("allows running an executable script by path", () => {
assert.deepEqual(
- auditGitAddBlocks(['./scripts/sync-versions.sh', 'git add .'].join('\n')),
+ auditGitAddBlocks(["./scripts/sync-versions.sh", "git add ."].join("\n")),
[],
);
});
-test('reports a staged path that no longer exists', () => {
- const source = ['git add \\', ' knowledge/_removed-context/context.md'].join(
- '\n',
+test("reports a staged path that no longer exists", () => {
+ const source = ["git add \\", " knowledge/_removed-context/context.md"].join(
+ "\n",
);
assert.match(auditGitAddBlocks(source)[0], /staged path does not exist/u);
});
-test('joins continued lines into one logical command', () => {
- const logical = toLogicalLines(['git add \\', ' a \\', ' b'].join('\n'));
- assert.deepEqual(
- logical.map((entry) => entry.text),
- ['git add a b'],
+test("joins continued lines into one logical command", () => {
+ const logical = toLogicalLines(["git add \\", " a \\", " b"].join("\n"));
+ assert.equal(logical.length, 1);
+ assert.deepEqual(logical[0].text.trim().split(/\s+/u), [
+ "git",
+ "add",
+ "a",
+ "b",
+ ]);
+});
+
+test("keeps a token split across a continuation adjacent, as bash does", () => {
+ // bash deletes the backslash-newline pair: `llms.tx\t` is llms.txt.
+ const logical = toLogicalLines(
+ ["git add packages/docs/public/llms.tx\\", "t"].join("\n"),
);
+ assert.equal(logical.length, 1);
+ assert.equal(logical[0].text, "git add packages/docs/public/llms.txt");
+ assert.deepEqual(auditGitAddBlocks(logical[0].text), []);
});
-test('the committed script passes its own audit', () => {
+test("the committed script passes its own audit", () => {
const source = readFileSync(
- join(REPO_ROOT, 'scripts/sync-release-generated.sh'),
- 'utf8',
+ join(REPO_ROOT, "scripts/sync-release-generated.sh"),
+ "utf8",
);
assert.deepEqual(auditGitAddBlocks(source), []);
});
From 034072d3f460c8016df1108f5a54d104392817e4 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Thu, 20 Aug 2026 12:57:33 +0900
Subject: [PATCH 3/3] fix: reject staged paths that escape the repository
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
git refuses to stage outside the work tree, so a token with a stray ../
fails the release rather than this audit — the release-only failure the
guard exists to prevent. Resolve each token and require it to stay inside
REPO_ROOT, while still allowing the root itself so 'git add .' passes.
Co-Authored-By: Claude Opus 5
---
scripts/audit-release-sync-script.mjs | 19 +++++++++++++++++--
scripts/audit-release-sync-script.test.mjs | 5 +++++
2 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/scripts/audit-release-sync-script.mjs b/scripts/audit-release-sync-script.mjs
index 6ba637fa9..54a032fb0 100644
--- a/scripts/audit-release-sync-script.mjs
+++ b/scripts/audit-release-sync-script.mjs
@@ -14,7 +14,7 @@ import {
accessSync,
constants,
} from "node:fs";
-import { dirname, join } from "node:path";
+import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -60,6 +60,14 @@ export function toLogicalLines(source) {
return logical;
}
+// git refuses to stage outside the work tree, so a token that escapes the
+// repository fails the release rather than this audit unless caught here.
+function insideRepo(target) {
+ const rel = relative(REPO_ROOT, target);
+ // An empty relative path is REPO_ROOT itself, which `git add .` stages.
+ return !rel.startsWith("..") && !isAbsolute(rel);
+}
+
function isRunnable(target) {
if (statSync(target).isDirectory()) return false;
try {
@@ -103,7 +111,14 @@ export function auditGitAddBlocks(source) {
.filter((token) => token && !token.startsWith("-"))
.forEach((token) => {
if (token.includes("$") || token.includes("*")) return;
- if (!existsSync(join(REPO_ROOT, token))) {
+ const target = resolve(REPO_ROOT, token);
+ if (!insideRepo(target)) {
+ failures.push(
+ `${SCRIPT}:${line}: staged path escapes the repository: ${token}`,
+ );
+ return;
+ }
+ if (!existsSync(target)) {
failures.push(
`${SCRIPT}:${line}: staged path does not exist: ${token}`,
);
diff --git a/scripts/audit-release-sync-script.test.mjs b/scripts/audit-release-sync-script.test.mjs
index 811c66f77..4015cb062 100644
--- a/scripts/audit-release-sync-script.test.mjs
+++ b/scripts/audit-release-sync-script.test.mjs
@@ -89,6 +89,11 @@ test("keeps a token split across a continuation adjacent, as bash does", () => {
assert.deepEqual(auditGitAddBlocks(logical[0].text), []);
});
+test("rejects a staged path that escapes the repository", () => {
+ const source = ["git add \\", " ../../../etc/passwd"].join("\n");
+ assert.match(auditGitAddBlocks(source)[0], /escapes the repository/u);
+});
+
test("the committed script passes its own audit", () => {
const source = readFileSync(
join(REPO_ROOT, "scripts/sync-release-generated.sh"),