Skip to content

Toolchain-profile compiler-family rendering — audited, args trust boundary hardened; the -stdlib=/--target= "fix" itself was wrong and has been reverted

Index: Known gaps

Status (re-verified 2026-10-09 at 456989f): POLICY_NOTE. Audit record: the wrong fix was reverted and the args trust boundary was hardened. _compose_gcc_options still exists (buildsource/run_plan_profile_fields.py:21-25).

An external audit found run_plan.py's _compose_gcc_options() composing -stdlib=/--target= unconditionally for any profiles.<id>.compile overlay, even when compile.compiler_family: gcc — both are Clang-driver-only spellings a real GCC binary rejects (confirmed against GCC 14.2), so an early pass dropped both whenever compiler_family resolved to a GCC family name. A later review round found that fix backwards: the composed string this function returns is never actually fed to a literal GCC binary anywhere in this pipeline — --ast-frontend only has auto/castxml/clang/hybrid (no gcc); castxml's own frontend is always its internal bundled Clang (--castxml-cc-<id> selects an emulation mode, not a literal execution path); and the direct-clang backend's _resolve_clang_bin (dumper_clang.py) explicitly rejects a gcc-path that isn't clang-family and falls back to host clang/clang++. Since the real consumer is always Clang, dropping --target= actively broke cross-compilation-target correctness for the direct-clang backend — it was the only signal available there to steer parsing away from the host architecture (no "probe the real compiler" auto-discovery step exists on that path the way castxml has one), so a GCC-family profile with an explicit target: would silently have its headers parsed for the runner's architecture instead. Reverted: _compose_gcc_options() emits -stdlib=/--target= unconditionally again, same as before the original audit, with both the change and the reasoning for reverting it recorded in the function's own docstring so a future reader doesn't rediscover and re-"fix" the same false positive. The same original audit flagged a real trust-boundary gap in profiles.<id>.compile.args, which is unaffected by this revert and stays fixed: the existing whitespace- smuggling check (_safe_profile_atom) rejected one YAML scalar expanding into multiple argv tokens, but not a single, whitespace-free dangerous atom. _DANGEROUS_ARG_PREFIXES (project_targets.py) now blocks four families of these: direct code-loading flags (-Xclang, -load, -fplugin=, -fpass-plugin=), file/argv re-expansion (@response-file, Clang's --config/--config=), driver command-line substitution (-specs=/--specs=, -wrapper), and — added across two follow-up review rounds on the same PR, since each is the same underlying "opaque subprocess-forwarding" mechanism as the others — GCC's -Wa,/-Wp,/-Wl, (comma-joined payload passed straight to the assembler/preprocessor/linker; -Wp,-fplugin=./evil.so reaches cc1 the same as a bare -fplugin=, -Wl,-plugin=./evil.dso loads an LTO linker plugin) and Clang's -Xpreprocessor/-Xassembler/-Xlinker (separate-argument equivalent of -Xclang). A third review round found a deeper issue than another missing flag spelling: every compile.* atom (not just args) now also rejects quote ('/") and backslash (\) characters, since _compose_gcc_options space-joins every field into one string that dumper.py's --gcc-options handling later re-splits with shlex.split() — an atom like "'-fplugin=./evil.so'" starts with a quote, not -fplugin=, so the prefix denylist alone accepted it, but POSIX shlex quote-removal reconstitutes the exact blocked flag on re-split (confirmed with an actual shlex.split() round-trip). Two more review rounds each found a flag real for the mechanism it names but empirically NOT exploitable through abicheck's actual pipeline — verified rather than taken on faith, and blocked anyway since doing so is free: --castxml-cc- (a second occurrence naively looks like it could replace the trusted --castxml-cc-<id> <path> pair dumper_ast_config.py composes ahead of args, but real castxml 0.6.3 hard-rejects any repeated --castxml-cc-* occurrence at argv-parse time instead of silently substituting the compiler); and -B<dir>/-B <dir> (GCC's compiler-component search path override really does let a planted cc1/cc1plus run instead of the real one, confirmed against real GCC — but every consumer of this composed string is Clang, not GCC, and Clang re-execs itself via -cc1 rather than spawning a separate, -B-discoverable one; confirmed neither castxml's internal bundled Clang nor the direct --ast-frontend clang backend ran a planted cc1 with -B set). A fifth review round found a flag family that IS actually exploitable through this pipeline, unlike the two immediately above: clang-cl's (Clang's MSVC-compatible driver mode — reachable via a compile.binding whose path stem contains "clang", e.g. clang-cl/clang-cl.exe, which dumper_clang._is_clang_family_binary recognizes as clang-family) /clang:<arg> escape hatch forwards an argument straight to the underlying clang driver, bypassing clang-cl's MSVC-shaped option parsing entirely — empirically confirmed exploitable: clang --driver-mode=cl "/clang:-fplugin=./evil.so" -c t.h really does load and run the planted plugin. /link <options> (clang-cl's documented "forward options to the linker") is blocked alongside it on the same LTO-linker-plugin grounds as the already-blocked -Wl,, without a from-scratch empirical repro of that specific sub-case. A sixth review round found a different shape of finding again: -cc1/-cc1as, Clang's internal frontend mode, only activates when -cc1/-cc1as is literally the first argument after the program name (confirmed empirically: -cc1 anywhere else is rejected as "unknown argument", including right after a leading -I) — but dumper.py's _build_clang_header_command builds argv as [cc_bin, *-I dirs, --sysroot, -nostdinc, *gcc_options tokens, ...], so a scan with no extra_includes/sysroot/nostdinc lets a leading -cc1 in compile.args genuinely land in that first-argument slot. Once in cc1 mode, -load/-fpass-plugin= were already blocked, but cc1 mode exposes an entirely different, much larger argument namespace this denylist was never designed to enumerate — Codex found -fcas-plugin-path (a cc1-only flag not present in every Clang build) doing the identical thing. Rejected the mode switch itself rather than chasing individual cc1-only flags, the same reasoning as --config. This denylist is necessarily reactive to the delivery mechanism, not exhaustive over every dangerous flag a mechanism could carry — a real fix for the whack-a-mole shape of this (an allowlist of known-safe ABI flags instead of a denylist of known-dangerous ones) was suggested during review but deliberately not done here: args is documented as a general escape hatch for ABI-relevant flags this codebase cannot enumerate a priori (GCC/Clang/MSVC each have their own vocabulary), and a strict allowlist would need that vocabulary built out first — its own scoped project, not a reactive expansion of this fix. (A fourth review round briefly caught a correctness gap in a since-reverted sentinel the family-aware _compose_gcc_options() fix needed — moot now that the fix itself is reverted, see above; not detailed here since it no longer applies to any code that ships.) Still not implemented, and out of scope for that fix (each needs its own scoped design, not a drive-by extension of the same narrow correction): a real toolchain-identity probe that validates a resolved binding's actual compiler family/version/target against the profile's declared constraints (compiler_version is still parsed but never checked against anything); a profile-specific AST frontend (there is still only one global --ast-frontend); and a genuine family-specific argv resolver — in particular MSVC /std://D spellings, which this fix does not attempt (no compiler_family: msvc caller/test exists yet to validate against, and a wrong guess here is worse than the pre-existing gap).