fix(core,producer,skills): unicode paths, non-Error rejections, shell callers

Three R3 findings.

The redactor's segment classes were ASCII `\w`, so `/数据/客户/秘密视频.mp4` and
`/data/客户/secret.mp4` went out verbatim — and the generic redactor also feeds
CLI telemetry and producer observation messages, where no known-path list
compensates. Segments are now defined by their delimiters instead of an
alphabet, which is correct for every script by construction rather than
requiring Unicode classes to be kept correct. The bare-relative lookbehind had
the same ASCII assumption and let a match start mid-token, redacting
`客户/秘密/视频.mp4` to `客户[path]`; it is now a token boundary, and
bare-relative runs before absolute so it claims the whole token.

sanitizeProbeFailure cast the rejection reason to Error and read `.message`.
An injected probe can reject with anything, so `Promise.reject("failed")` gave
`undefined` and threw inside the redactor — converting a returned failure
result into a rejected promise. Normalized at the boundary, and
redactKnownPaths no longer throws on a non-string.

The contract only admitted .ts/.js/.mjs/.cjs, so it missed shipped shell and
Python callers. frame_strip.sh passed a user-controlled path as ffprobe's last
positional with no terminator; render-and-composite.sh had four more. Both
fixed, and the sweep now covers .py/.sh. Python list argvs are bracket
literals so they get the same position check; shell command lines get a
separate presence check, because checking position there needs a shell parser
— stated as the weaker guarantee it is rather than implied to be equal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-04 03:18:21 -07:00
co-authored by Claude Opus 5
parent 6c5403f7cd
commit 1664fe6ad7
8 changed files with 169 additions and 19 deletions
@@ -86,6 +86,39 @@ describe("redactTelemetryString", () => {
});
});
// The segment classes were ASCII `\w`, so a non-Latin path went out verbatim.
// This redactor also feeds CLI telemetry and producer observation messages,
// where no known-path list is supplied to compensate.
describe("non-Latin paths", () => {
it.each([
"/数据/客户/秘密视频.mp4",
"/данные/клиент/видео.mp4",
"/data/客户/secret.mp4",
"/Users/alice/проект/видео.mp4",
])("redacts the absolute path %s", (path) => {
const out = redactTelemetryString(`ffprobe failed reading ${path}`);
expect(out).toBe("ffprobe failed reading [path]");
});
it("redacts a non-Latin bare relative path without stranding the first segment", () => {
// The lookbehind used to be `\w`-based, so a match could start mid-token
// when the preceding character was non-ASCII: this redacted to `客户[path]`.
expect(redactTelemetryString("could not open 客户/秘密/视频.mp4")).toBe(
"could not open [path]",
);
});
it.each(["./资产/背景.mp3", "../输出/final.wav"])("redacts the relative path %s", (path) => {
expect(redactTelemetryString(`could not open ${path}`)).toBe("could not open [path]");
});
it("redacts a non-Latin bare basename", () => {
expect(redactTelemetryString("Invalid data found in 秘密视频.mp4")).toBe(
"Invalid data found in [file]",
);
});
});
describe("redactKnownPaths", () => {
// Shape matching has holes by construction. A caller that built the argv
// knows the exact path, so it can name it instead of hoping a regex does.
@@ -113,4 +146,11 @@ describe("redactKnownPaths", () => {
it("ignores paths too short to be distinctive", () => {
expect(redactKnownPaths("a stream at a rate", ["a"])).toBe("a stream at a rate");
});
// This sits on an error path: throwing here turns a reported failure into an
// unhandled rejection, which is what a non-Error rejection's `undefined`
// message caused.
it.each([undefined, null, 42, {}])("returns a string for the non-string input %s", (value) => {
expect(() => redactKnownPaths(value as unknown as string, ["/tmp/x.mp4"])).not.toThrow();
});
});
+26 -6
View File
@@ -15,10 +15,24 @@ function redactUrlQueryStrings(value: string): string {
* prose after it, and a path with a space still gets its remaining segments
* redacted, which is the part that carries the identifying information.
*/
const SEGMENT = String.raw`[\w.\-@+()~]+`;
/**
* A path segment is defined by what ENDS it, not by an alphabet.
*
* `[\w...]` is ASCII-only, so `/数据/客户/秘密视频.mp4` and `/data/客户/secret.mp4`
* passed through completely unredacted — the generic redactor also feeds CLI
* telemetry and producer observation messages, where no known-path list is
* supplied to cover for it. Enumerating Unicode classes instead (`\p{L}\p{N}…`)
* would work but has to be kept correct for marks, joiners and emoji; a
* delimiter-based rule is right for every script by construction.
*
* Whitespace and quotes end a segment; so do the separators themselves.
* Space stays excluded for the original reason: including it would let a match
* run past the path and swallow the prose after it.
*/
const SEGMENT = String.raw`[^\s/\\'"]+`;
/** Same, minus the dot, so a trailing `.ext` can be matched separately. */
const SEGMENT_NODOT = String.raw`[\w\-@+()~]+`;
const SEGMENT_NODOT = String.raw`[^\s/\\'".]+`;
/**
* Once a match is established as a path, consume the rest of the token.
@@ -63,7 +77,7 @@ const RELATIVE_PATH = new RegExp(
* whose host we deliberately keep.
*/
const ASSET_BASENAME =
/(?<![\w/\\])[\w.\-@+()~]+\.(?:mp4|mov|mkv|webm|avi|m4v|mpe?g|ts|mp3|wav|aac|m4a|flac|ogg|opus|png|jpe?g|gif|webp|svg|html?|json|srt|vtt|ass)\b/gi;
/(?<![\w/\\])[^\s/\\'"]+\.(?:mp4|mov|mkv|webm|avi|m4v|mpe?g|ts|mp3|wav|aac|m4a|flac|ogg|opus|png|jpe?g|gif|webp|svg|html?|json|srt|vtt|ass)\b/gi;
/**
* A relative path with NO `./` prefix — `assets/bgm.mp3`,
@@ -80,11 +94,11 @@ const BARE_RELATIVE_PATH = new RegExp(
[
// Two or more separators: `customer/acme/video.mp4`. No extension needed —
// that much structure is already a path.
String.raw`(?<![\w/\\.:@-])(?:${SEGMENT}[\\/]){2,}${SEGMENT}${TOKEN_TAIL}`,
String.raw`(?<![^\s'\"(=,\[])(?:${SEGMENT}[\\/]){2,}${SEGMENT}${TOKEN_TAIL}`,
// One separator, but the last segment carries a file extension:
// `assets/bgm.mp3`. That segment is dot-free on purpose — SEGMENT includes
// `.`, so a greedy one swallows the extension this rule needs.
String.raw`(?<![\w/\\.:@-])${SEGMENT}[\\/]${SEGMENT_NODOT}\.\w{1,8}\b${TOKEN_TAIL}`,
String.raw`(?<![^\s'\"(=,\[])${SEGMENT}[\\/]${SEGMENT_NODOT}\.\w{1,8}\b${TOKEN_TAIL}`,
].join("|"),
"g",
);
@@ -99,6 +113,10 @@ const BARE_RELATIVE_PATH = new RegExp(
* reports only that.
*/
export function redactKnownPaths(value: string, paths: readonly string[]): string {
// Fail soft on a non-string. This sits on an error path, so throwing here
// converts a reported failure into an unhandled rejection — which is exactly
// what happened when a caller passed a non-Error rejection's `.message`.
if (typeof value !== "string") return "";
let out = value;
for (const path of paths) {
if (typeof path !== "string" || path.length === 0) continue;
@@ -120,8 +138,10 @@ function redactFilePaths(value: string): string {
// (`/assets/x.mp3`), so the absolute rule would consume it and leave the
// leading `.` stranded outside the redaction.
.replace(RELATIVE_PATH, "[path]")
.replace(ABSOLUTE_PATH, "[path]")
// Bare-relative BEFORE absolute: a bare path's interior satisfies the
// absolute rule, which would claim it and strand the first segment.
.replace(BARE_RELATIVE_PATH, "[path]")
.replace(ABSOLUTE_PATH, "[path]")
.replace(ASSET_BASENAME, "[file]")
);
}
@@ -337,4 +337,45 @@ describe("PadTrimAudioResult.error never carries the input path", () => {
expect(result.error ?? "").not.toContain("acme-secret");
expect(result.error ?? "").toContain("failed to probe audio");
});
// An injected probe can reject with anything. Casting the reason to Error and
// reading `.message` yielded undefined, which threw inside the redactor and
// turned a returned failure result into a rejected promise.
describe("a probe that rejects with a non-Error value", () => {
const nonErrors: Array<[string, unknown]> = [
["a string", "probe failed"],
["undefined", undefined],
["null", null],
["a number", 42],
["a plain object", { code: "ENOENT" }],
];
for (const [label, reason] of nonErrors) {
it(`still returns a failed result when the video probe rejects with ${label}`, async () => {
const result = await padOrTrimAudioToVideoFrameCount({
videoPath: "/data/acme-secret/video.mp4",
audioPath: "/tmp/audio.aac",
outputPath: "/tmp/out.aac",
probeVideoFrameInfo: () => Promise.reject(reason),
probeAudioInfo: () => Promise.resolve({ durationSeconds: 1 }),
runFfmpeg: () => Promise.resolve({ success: true }),
});
expect(result.success).toBe(false);
expect(result.error ?? "").toContain("failed to probe video");
});
it(`still returns a failed result when the audio probe rejects with ${label}`, async () => {
const result = await padOrTrimAudioToVideoFrameCount({
videoPath: "/tmp/v.mp4",
audioPath: "/data/acme-secret/audio.aac",
outputPath: "/tmp/out.aac",
probeVideoFrameInfo: () => Promise.resolve({ frameCount: 30, fpsNum: 30, fpsDen: 1 }),
probeAudioInfo: () => Promise.reject(reason),
runFfmpeg: () => Promise.resolve({ success: true }),
});
expect(result.success).toBe(false);
expect(result.error ?? "").toContain("failed to probe audio");
});
}
});
});
@@ -230,7 +230,13 @@ function formatSeconds(sec: number): string {
* recognise them), then the generic shape-based scrub for anything the message
* picked up elsewhere.
*/
function sanitizeProbeFailure(message: string, paths: readonly string[]): string {
function sanitizeProbeFailure(reason: unknown, paths: readonly string[]): string {
// Normalized here, not at the call sites. A caller-supplied probe can reject
// with anything — `Promise.reject("probe failed")` has no `.message`, so
// casting to Error yielded `undefined` and threw inside the redactor. That
// turned a returned failure result into a rejected promise, which is a
// behaviour regression the cast introduced.
const message = reason instanceof Error ? reason.message : String(reason);
return redactTelemetryString(redactKnownPaths(message, paths));
}
@@ -261,7 +267,7 @@ export async function padOrTrimAudioToVideoFrameCount(
0,
audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
`audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
(videoResult.reason as Error).message,
videoResult.reason,
probePaths,
)}`,
);
@@ -272,7 +278,7 @@ export async function padOrTrimAudioToVideoFrameCount(
0,
0,
`audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
(audioResult.reason as Error).message,
audioResult.reason,
probePaths,
)}`,
);
@@ -32,7 +32,20 @@ const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..");
const SWEEP_ROOTS = ["packages", "skills", "scripts"];
/** `.mjs`/`.cjs` are first-class here — the skill scripts are not TypeScript. */
const SOURCE_EXT = /\.(?:ts|mjs|cjs|js)$/;
const SOURCE_EXT = /\.(?:ts|mjs|cjs|js|py|sh)$/;
/**
* Shell-syntax invocations, checked separately and more weakly.
*
* A JS/Python argv is a bracketed literal, so the parser above can check that
* `--` is the PENULTIMATE entry. A shell command line is not a literal
* `ffprobe -v error ... "$BG" 2>/dev/null | tr -dc '0-9.'` has redirections,
* pipes and substitutions after the input so checking position would need a
* shell parser. This asserts the terminator is PRESENT on any ffprobe command
* line, which is weaker but is the part that was missing, and it is honest
* about being weaker rather than implying the same guarantee.
*/
const SHELL_EXT = /\.sh$/;
/**
* The caller set as of the sweep that introduced this contract.
@@ -139,11 +152,15 @@ function isSourceFile(entry: string): boolean {
return true;
}
function discoverCallers(): { found: string[]; unclassified: string[] } {
function discoverCallers(): { found: string[]; unclassified: string[]; shell: string[] } {
const found: string[] = [];
const unclassified: string[] = [];
const shell: string[] = [];
const classify = (abs: string): void => {
const src = readFileSync(abs, "utf8");
if (SHELL_EXT.test(abs) && /(?:^|[^\w-])ffprobe\s+-/m.test(src)) {
shell.push(relative(REPO_ROOT, abs));
}
// Discovery is ARGV-shaped, not call-shaped. Matching on spawn/execFile
// misses a dependency-injected runner — `runner("ffprobe", [...])` in
// studio-server's mediaValidation.ts is exactly that, and a call-shaped
@@ -168,7 +185,7 @@ function discoverCallers(): { found: string[]; unclassified: string[] } {
/* root absent in a partial checkout */
}
}
return { found: found.sort(), unclassified: unclassified.sort() };
return { found: found.sort(), unclassified: unclassified.sort(), shell: shell.sort() };
}
/**
@@ -211,6 +228,32 @@ function argvTails(source: string): Array<{ snippet: string; tail: string[] }> {
return tails;
}
describe("shell ffprobe invocations terminate their options", () => {
const shellFiles = discoverCallers().shell;
it("finds the shell callers", () => {
// Guards the guard: `frame_strip.sh` and `render-and-composite.sh` both
// shipped un-terminated while the JS-only sweep reported the class closed.
expect(shellFiles.length).toBeGreaterThan(0);
});
it.each(shellFiles)("%s passes -- on every ffprobe command line", (relPath) => {
const source = readFileSync(join(REPO_ROOT, relPath), "utf8");
const offenders = source
.split("\n")
.map((line, index) => ({ line: line.trim(), number: index + 1 }))
// An invocation passes flags. `command -v ffprobe >/dev/null` is a PATH
// check and `echo "ffmpeg/ffprobe not on PATH"` is a message; neither
// takes an input, and both were reported before this narrowed.
.filter(({ line }) => /(?:^|[^\w-])ffprobe\s+-/.test(line) && !line.startsWith("#"))
.filter(({ line }) => !/\b(?:command\s+-v|which|type)\s+ffprobe/.test(line))
.filter(({ line }) => !/\s--\s/.test(line))
.map(({ line, number }) => `${number}: ${line.slice(0, 80)}`);
expect(offenders, `${relPath}: ffprobe command lines missing "--"`).toEqual([]);
});
});
describe("ffprobe argv contract", () => {
const { found: callers, unclassified } = discoverCallers();
+2 -2
View File
@@ -2,7 +2,7 @@
"source": "heygen-com/hyperframes",
"skills": {
"embedded-captions": {
"hash": "8e8bd824567c3e17",
"hash": "e8c2c3b6dfd04b39",
"files": 140
},
"faceless-explainer": {
@@ -66,7 +66,7 @@
"files": 28
},
"remotion-to-hyperframes": {
"hash": "3a0e6c2affb9f74e",
"hash": "3ecc684432b298dd",
"files": 70
},
"slideshow": {
@@ -352,8 +352,8 @@ else
fi
# Probe render dims for ffmpeg scale
W="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=nw=1:nk=1 "$BG")"
H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=nw=1:nk=1 "$BG")"
W="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=nw=1:nk=1 -- "$BG")"
H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=nw=1:nk=1 -- "$BG")"
# Clamp every composite to the matte (= source-video) length. The render uses
# plan.duration / data-duration, which can exceed the source (e.g. Whisper word
@@ -365,7 +365,7 @@ H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of defaul
# the a-roll stream ends). The true a-roll duration is authoritative: clamp to
# min(matte frames / fps, source duration).
MATTE_DUR="$(awk "BEGIN{printf \"%.3f\", $(ls "$PROJECT/frames_fg" | wc -l)/$FPS}")"
SRC_DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$PROJECT/source.mp4" 2>/dev/null || true)"
SRC_DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 -- "$PROJECT/source.mp4" 2>/dev/null || true)"
if [[ -n "${SRC_DUR:-}" ]]; then
MATTE_DUR="$(awk "BEGIN{m=$MATTE_DUR; s=$SRC_DUR; printf \"%.3f\", (s>0 && s<m) ? s : m}")"
fi
@@ -378,7 +378,7 @@ echo "[render] clamp output to source/matte length: ${MATTE_DUR}s"
# last-caption time instead of the clip length. Clamp to the bg length so we never
# ship the only-foreground tail, and tell the author the real fix.
if [[ -f "$BG" ]]; then
BG_DUR="$(ffprobe -v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 "$BG" 2>/dev/null | tr -dc '0-9.')"
BG_DUR="$(ffprobe -v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 -- "$BG" 2>/dev/null | tr -dc '0-9.')"
if [[ -n "$BG_DUR" ]] && awk "BEGIN{exit !($BG_DUR < $MATTE_DUR - 0.3)}"; then
echo "[render] ⚠ background plate is ${BG_DUR}s but the clip is ${MATTE_DUR}s — the composition is shorter than the footage." >&2
echo " The tail would show ONLY the foreground subject on black. FIX: set the composition" >&2
@@ -53,7 +53,7 @@ probe = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=r_frame_rate,nb_read_frames,duration",
"-show_entries", "format=duration",
"-of", "json", "-count_frames", baseline],
"-of", "json", "-count_frames", "--", baseline],
check=True, capture_output=True, text=True,
)
data = json.loads(probe.stdout)