Repository navigation
Conversation
A native Windows jq used from Git Bash ends each output line with CRLF. In _acq_msb_ports_from_inspect the trailing CR made every "H:G" record fail the digit guard except the last, so `acq ports` under-reported create-time ports. Strip carriage returns from the jq output before validation and keep the digit guard. Add a regression test with a stub jq that emits CRLF line endings. Fixes: #648 Co-authored-by: OpenCode [claude-sonnet-5.5] <bret.mogilefsky@gsa.gov>
The provider-input transaction wrapper reads the guardian's parent PID and the guardian's start-time identity with `ps -o ppid=` and `ps -o lstart=`. MSYS `ps` (Git Bash) has no `-o` at all - it prints "ps: unknown option -- o" and nothing else - so the parent PID came back empty and _acq_provider_input_guardian_start returned 1. Every `acq terminate` then exited before _acq_backend_terminate_unlocked ran, which is why backend teardown and the recorded port teardown behind it never happened. Keep the POSIX commands first so POSIX behaviour is unchanged, and fall back only when they yield nothing: - _acq_provider_input_parent_pid parses `ps -p PID`, which MSYS does support, selecting the row by exact PID match and taking its PPID column. It is correct with or without the header line, and still fails closed on empty or non-numeric output. - _acq_provider_input_process_identity falls back to procfs field 22 (starttime ticks), the value Linux ps derives lstart from. MSYS STIME was rejected deliberately: its one-second resolution cannot distinguish a recycled PID from the original lock owner. Refs: ADR-0038
Windows CPython has no os.O_DIRECTORY, no os.O_NOFOLLOW and no dir_fd support, so _acq_provider_input_safe_mkdir and _acq_provider_input_safe_dir could not run there at all. Both now delegate to acq.backends/provider_input_pathguard.py when sys.platform == 'win32'; the POSIX walk is untouched. The new module keeps the descriptor-anchored guarantee with the Win32 API: every component is opened with FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS (the O_NOFOLLOW equivalent, plus the flag a directory handle needs), rejected when GetFileInformationByHandleEx reports FILE_ATTRIBUTE_REPARSE_POINT or a non-directory (the O_DIRECTORY check), and required to resolve to exactly the vetted parent's name plus the component (the descriptor-relative walk's anti-TOCTOU property). Missing components are created with CreateDirectoryW, which inherits the parent ACL. There is no os.makedirs fallback anywhere: rejections raise the same SystemExit payload the POSIX path produces. The shell hands the guard a Windows spelling it built from /proc/mounts rather than letting MSYS rewrite the argument, because that rewrite resolves junctions and symlinks - a guard fed the resolved path would honour exactly the redirect it exists to refuse. cygpath is deliberately not used, for the same reason. Refs: ADR-0038
Add host-neutral regression tests for the two Windows gaps. The link tests use the host's own mechanism - a POSIX symlink, or a Windows junction via mklink /J - so one shared traversal invariant is asserted everywhere instead of a Windows-only truth, and a host that cannot create a link fails the test rather than skipping it. The rest cover the guardian's transaction parent, identity stability and fail-closed behaviour on garbage input, and the POSIX-compatible diagnostic for a relative path. The msb port-list CRLF defect found during this work is covered by #663, which this branch is based on.
ADR-0038 explains why the Win32 handle walk is equivalent to the POSIX O_NOFOLLOW/dir_fd walk, why the path spelling is built from the mount table instead of cygpath, why procfs starttime replaced STIME for lock identity, and what residual risk remains (components are opened by name; volume mount points are rejected). docs/KNOWN_FAILURE_MODES.md records the two Windows gaps that are deferred and tracked rather than fixed here: provider-input staging still uses O_DIRECTORY, and the msb create TERM suite test now reaches (and hangs in) a pre-existing MSYS process/signal path.
2309a7a to
9c127c3
Compare
basiliskus
left a comment
There was a problem hiding this comment.
Verdict: approve — 9c127c3339d1f3287282ae04e8d291260ede699d
No findings. The Windows path guard fails closed, rejects reparse points, preserves the existing POSIX path, and the process-identity fallbacks validate their inputs. The regression tests cover ordinary paths, link ancestors and leaves, parent-PID discovery, and stable process identity.
Native Windows execution was not available in this review environment, and the local Bats suite could not run because its vendored submodules were absent. ShellCheck passed, and the current GitHub checks are green.
Assisted-by: OpenCode [gpt-5.6-sol-latest-guardrails-defaultv2] skill:change-review
…input-guard Resolve the docs/KNOWN_FAILURE_MODES.md conflict introduced when #661 merged. Both sides appended a section 50: main's "SBX: Readonly Kit Files Are Guest-Resident Copies" keeps number 50, and this branch's two Windows entries are renumbered to 51 (provider-input staging) and 52 (msb create TERM suite hang). Nothing in the tree referenced the old numbers. No code changed; common.sh and the 147 bats file merged cleanly.
basiliskus
left a comment
There was a problem hiding this comment.
Verdict: request changes — 6ea1758ebd86f9fef9bdb48a3ad853e1f8ec8c1f
- Major —
acq.backends/provider_input_pathguard.py:207-212: Literal short-name components can compare unequal to normalized handle names, aborting teardown for legitimate Windows paths. The comparison failure reproduces with production_vetandntpath; native Windows confirmation remains needed. Preserve reparse rejection, compare the normalized child’s parent against the vetted parent, and add short-name regression coverage.
Assisted-by: OpenCode [gpt-6.1-sol-latest-guardrails-defaultv2] skill:change-review
The component check compared the handle's resolved name with the literal component text, so a legitimate path containing an 8.3 alias (PROGRA~1, RUNNER~1) was rejected as redirected and aborted teardown. The kernel reports the long name for such a handle. Compare the resolved child's parent directory with the vetted parent's resolved path instead, and require the leaf to be a real name (no dot components, no stream). Reparse-point and directory attribute rejection still run first. Use ntpath explicitly for Windows path logic so it behaves identically off Windows, strip the \\?\UNC\ prefix case-insensitively, and reject a colon in any input component. Add an offline harness that drives the real walk over a fake volume, and record the comparison semantics in ADR-0038. Short-name behaviour on a real volume is not verified here. Refs: #667 Co-authored-by: OpenCode [claude-sonnet-5.5] <bret.mogilefsky@gsa.gov>
|
🤖: AI agent (OpenCode), posting on behalf of @mogul. Request: Windows host validation of the new head of this PR. Head Tested revision: Host requirements: real Windows (not WSL), native Python 3.12 (not MSYS Python), an elevated :: 0. Environment (paste output)
python -c "import os,sys; print('os.name=',os.name,'platform=',sys.platform,sys.version.split()[0])"
git rev-parse HEAD
ver
:: 1. Is 8.3 name creation enabled on C:? (paste output)
fsutil 8dot3name query C:
:: If creation is disabled, REPORT that, then enable it for this test only and restore the original value afterwards:
:: fsutil 8dot3name set C: 0
:: Existing directories do not get short names retroactively, so create the test tree AFTER enabling it.
:: 2. Create a long-named tree and find its alias (paste output of dir /x)
mkdir "C:\acq83\Long Directory Name\tree"
dir /x C:\acq83
:: Expect a row like: LONGDI~1 Long Directory Name (note the exact alias; use it below in place of LONGDI~1)
:: 3. Does the kernel report the LONG name for the alias handle? (the premise of the fix)
python -c "import sys; sys.path.insert(0,'acq.backends'); import provider_input_pathguard as g; h=g._open_directory(r'C:\acq83\LONGDI~1'); print(g._final_path(h)); g._close(h)"
:: Expect: C:\acq83\Long Directory Name
:: 4. The guard accepts a legitimate path that goes through the alias
python -c "import sys; sys.path.insert(0,'acq.backends'); import provider_input_pathguard as g; g.safe_dir(r'C:\acq83\LONGDI~1\tree'); print('accepted')"
echo exit=%ERRORLEVEL%
:: Expect: accepted, exit=0.
:: Baseline: repeat step 4 at 6ea1758e (git checkout --detach 6ea1758e, same command). Expect exit=1 (rejected as redirected). Then return to the tested SHA.
:: 5. Redirects are still refused
mklink /J C:\acq83\evil C:\Windows
python -c "import sys; sys.path.insert(0,'acq.backends'); import provider_input_pathguard as g; g.safe_dir(r'C:\acq83\evil\System32')"
echo exit=%ERRORLEVEL%
:: Expect: exit=1, no traceback. Also try an alias that points through the junction: dir /x C:\acq83 (is there a short name for "evil"?) and repeat with that alias; expect exit=1.
:: 6. safe_mkdir with an alias path creates the child under the long-named directory
python -c "import sys; sys.path.insert(0,'acq.backends'); import provider_input_pathguard as g; g.safe_mkdir(r'C:\acq83\LONGDI~1\made'); print('made')"
dir "C:\acq83\Long Directory Name"
:: Expect: made, and a directory named "made" listed under "Long Directory Name" (not a second tree).Then, from Git Bash on the same host and checkout: # 7. Existing suites that cover the guard (paste the TAP summary and every 'not ok' line)
git submodule update --init test/vendor/bats-core test/vendor/bats-support test/vendor/bats-assert
./scripts/test-acq-bats test/bats/157-provider-input-pathguard-vet.bats test/bats/112-msb-ports.bats test/bats/117-msys-path-forms.bats
# 147 can hang on this host (see the KNOWN_FAILURE_MODES entry for the msb create TERM test); run it with a per-test timeout or skip that test, and say which:
# ./scripts/test-acq-bats -f 'windows:' test/bats/147-provider-input-projection.bats
# 8. Real teardown through a short-name workspace path (only if a sandbox backend is usable; otherwise report BLOCKED for this step)
# Place a workspace under a path containing an 8.3 alias, e.g. a directory under C:\acq83\LONGDI~1, run acq create/terminate or the 112 stub flow against it, and confirm teardown runs (no 'unsafe provider-input path').Expected (judge against these): step 3 prints the long name; step 4 Please report: step 0 and 1 output, the tested SHA, the output of steps 2 to 6 verbatim, the step 7 TAP lines, whether step 8 ran, and an explicit PASS, FAIL or BLOCKED verdict per step plus an overall verdict. Clean up: |
|
🤖: AI agent (OpenCode [deepseek-v4.1-flash]) on the Windows host rocinante-w11, posting on behalf of @mogul. Windows validation of Tested revision
Verdict per step
Overall: BLOCKED (partially verified). The accept/reject behaviour and the kernel-long-name premise are verified on a real volume using pre-existing aliases. Creating a fresh 8.3 alias is impossible on this host, so that path — and the alias-through-junction sub-case of step 5 — remain unverified. Step 0 — environmentStep 1 —
|
Summary
acq terminatecould not run at all on a Windows host: the provider-inputtransaction wrapper aborted before it reached backend teardown, so recorded port
state survived and
acq rm/acq stopleft orphan tunnels behind. This makesthe guard work on Windows without weakening it, and leaves the POSIX path
byte-for-byte unchanged.
Two independent host gaps caused it:
_acq_provider_input_safe_mkdirand
_acq_provider_input_safe_dirwalk a path withos.open(..., O_DIRECTORY | O_NOFOLLOW, dir_fd=...). Native Windows CPythonhas no
os.O_DIRECTORY, noos.O_NOFOLLOW, and nodir_fdsupport, andcannot open a directory as a file descriptor at all, so the step raised and the
transaction aborted.
ps -o._acq_provider_input_guardian_startand_acq_provider_input_process_identityranps -o ppid=/ps -o lstart=.MSYS
pshas no-o(it printsps: unknown option -- oand nothing else),so the guardian had no transaction parent and aborted with
return 1.Dependency on #663 (merged)
#663 (the native Windows
jqCRLF fix) hasmerged, so this PR now targets
maindirectly and no longer depends on anotheropen PR. This branch carries one commit that duplicates it (
1b0f165); it isidentical to the merged change and drops out of the diff against
main.test/bats/112-msb-ports.batstest 13 (LIST mode surfaces create-time + post-hoc ports …) needs that CRLF fix, which lives in #663 and not here:acq.backends/msb.shis unchanged by this PR. Test 11 (rm and stop tear down recorded port state …) needs the guard fix in this PR.Review follow-up: the per-component check no longer compares the resolved name
with the literal component text. It now requires the resolved path to be a
direct child of the vetted parent's resolved path, so a legitimate path that
contains an 8.3 short-name alias (for example
PROGRA~1) is accepted, while acomponent that resolves under a different parent is still rejected and
reparse-point rejection still runs first. The comparison semantics are recorded
in
docs/adr/0038-windows-provider-input-guards.md, andtest/bats/157-provider-input-pathguard-vet.batscovers them offline on anyhost. What a real Windows volume reports for a short name is not verified here;
a Windows-host check is requested on this PR.
The branch has since merged
maintwice (once after#661, once after
#664). The only conflict was in
docs/KNOWN_FAILURE_MODES.md, where both sides added a section 50: main'skeeps 50 and this PR's two Windows entries are now 51 and 52. No code changed.
#644 overlaps this PR in
acq.backends/common.shandtest/bats/147-provider-input-projection.bats, andwill be rebased onto this one after it merges.
Related Issues
(validation comment):
test/bats/112-msb-ports.batstest 11 failed on real Windows, identically onthe parent commit, so it is a pre-existing Windows bug rather than a regression
from fix(msb): strip CR from jq output in acq ports (Windows) #663.
docs/adr/0038-windows-provider-input-guards.md.Type of Change
Checklist
Context: the failure mechanism
acq_backend_terminateruns inside a transaction wrapper: start a guardian,acquire the provider-input lock, then call the unlocked terminate. Both steps
depend on host facilities Windows does not provide in the POSIX shape:
guardian's start-time identity. Both came from
ps -o, which MSYS lacks, so_acq_provider_input_guardian_startreturned 1 andacq_backend_terminateexited before
_acq_backend_terminate_unlockedran._acq_msb_ports_teardowntherefore never ran, and
$STUBDIR/state/ports/rmbox.pidssurvived.O_DIRECTORY/O_NOFOLLOW/dir_fd.Calling
_acq_msb_ports_teardowndirectly worked (rc=0, file removed), whichconfirmed the teardown logic itself was sound and the wrapper was the whole
problem.
Fix
Path walk —
acq.backends/provider_input_pathguard.py(new). Each componentis opened with
CreateFileWusingFILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS(theO_NOFOLLOWequivalent, plus the flag a directory handle needs) and vetted with
GetFileInformationByHandleEx(FileAttributeTagInfo): aFILE_ATTRIBUTE_REPARSE_POINT(symlink, junction, mount point) or anon-directory is rejected, which is the
O_DIRECTORYcheck. The handle's ownresolved name must equal the parent handle's resolved name plus the component,
which is the descriptor-relative walk's anti-TOCTOU property expressed as an
identity check. Missing components are created with
CreateDirectoryW(whichinherits the parent ACL — the Windows analogue of the POSIX
0o755mkdirhonouring umask). There is no
os.makedirsfallback anywhere: rejectionsraise, and the caller sees the same
SystemExitpayload the POSIX path produces.Why the shell translates the path first. The MSYS runtime rewrites
POSIX-looking argv values for native programs, and that rewrite resolves
junctions and symlinks — a guard fed the rewritten value could not tell a
pre-created link from a real directory and would honour the redirect. So the
shell builds the Windows spelling from
/proc/mounts(the MSYS-owned mounttable: longest matching mount point replaced by its Windows form, everything
below appended verbatim, never resolved) and passes that; an already-drive-form
argv value passes through MSYS untouched.
cygpathis deliberately not used:it resolves links inside the path, which is exactly the information the guard
must keep.
Process guards.
_acq_provider_input_parent_pidkeepsps -o ppid=firstand falls back to parsing
ps -p PID(MSYS printsPID PPID PGID WINPID TTY UID STIME COMMAND), selecting the row by exact PIDmatch so it works with or without a header.
_acq_provider_input_process_identitykeeps
ps -o lstart=first and falls back to procfs/proc/<pid>/statfield 22(starttime ticks — the value Linux
psderiveslstartfrom). MSYSSTIMEwasrejected on purpose: one-second resolution cannot distinguish a recycled PID from
the original lock owner. Both paths still fail closed on empty or non-numeric
output.
Test Results
Host: native Windows 11, Git Bash (
MINGW64_NT-10.0-26200), native Python3.12.10 (reports
sys.platform == 'win32'), native jq 1.8.2.Red → green (
test/bats/112-msb-ports.batstest 11)Parent commit
ef24965, clean worktree, before any change:This branch (measured before #663 merged, on top of
fix/648-msb-ports-crlf):Full-file summary on the new head:
1..18, 18ok, no failures, exit 0.Test 13 also passes here, but only because the base carries
#663's
tr -d '\r'fix — on this branch alone(
ef24965+ this branch's guard commits, without #663) test 13 was still red, which is why it wasmeasured on top of
fix/648-msb-ports-crlf; that fix is now inmain.Other suites on this host
These were captured on the pre-merge head (before
mainwas merged in). The rebase changed no code theyexercise:
acq.backends/msb.shis byte-identical to the base (comment wordingonly),
test/bats/112-msb-ports.batsis byte-identical to the base (thisbranch's duplicate CRLF test removed), and
acq.backends/common.shplus the newmodule are unchanged.
test/bats/112-msb-ports.batstest/bats/146-provider-verification.batstest/bats/117-msys-path-forms.batstest/bats/120-workspace-preflight.batsidentity (b) … symlinked child not followedfails identically onef24965test/bats/147-provider-input-projection.batsscripts/run-shellcheck(every touched shell file).markdownlint.jsonc) on touched docsEvery one of the 15
147failures reproduces onef24965. I re-ran each ona clean worktree of that commit: the four
setup validator …tests, the twocandidate snapshot …tests,failed validator dependency probe …,projection prepares immutable generation …,parent exit cannot make a live transaction lock recoverable,unknown backend/msb existence …,rejected replacement …,failed atomic swap …,empty discovery publishes …, andmsb remove TERM …. Thecandidate snapshot/ projection failures are thedeferred
_acq_provider_input_snapshotgap (AttributeError: module 'os' has no attribute 'O_DIRECTORY'), recorded indocs/KNOWN_FAILURE_MODES.mdrather thanfixed here. The validator tests are POSIX-only by design.
Test 36 (
msb create TERM …) changed shape on Windows. Onef24965itfails fast with
slow-msb-create never started(provisioning aborts at theguard). With the guard fixed it gets far enough for the test's slow
msb createstub to start, and the run then does not complete (>300 s). The exact blocking
point is unconfirmed and is in the MSYS process/signal model, not in the guard;
it is tracked as failure mode 51 in
docs/KNOWN_FAILURE_MODES.md. This is theone behaviour change in this PR that is not a fix, and it is why the
147run isreported as partial rather than green.
What I could not verify locally
for the Linux suite and for the
bash 3.2gate(
.github/workflows/bash32-compat.yml), which runs this same offline suiteunder bash 3.2.57.
case/parameter expansion and ordinary utilities (no${x,,}, no associativearrays, no
mapfile); the new tests avoid bash-4-only features.tests pick the host's own link mechanism, so Linux exercises the symlink case
and Windows the junction case for the same invariant.
Rollback
Delete
acq.backends/provider_input_pathguard.py, thesys.platform == 'win32'branches and Windows helpers in
acq.backends/common.sh, the added tests, andthe ADR. The POSIX path was never modified, so nothing needs restoring for
Linux/macOS. Nothing in
acq.backends/msb.shis this branch's to roll back.Security Impact
points on every component below the mount anchor, and rejects a child that
resolves outside its vetted parent. No fallback can silently follow a link.
drive root is rejected, where POSIX would accept a bind mount.
(Win32 has no handle-relative open) and the identity check runs on the handle
just opened. The pre-created-ancestor threat this guard exists for is fully
covered; a concurrent rename race would need
NtCreateFileand is not closedhere.
docs/KNOWN_FAILURE_MODES.md:_acq_provider_input_snapshotstill uses
O_DIRECTORY/O_NOFOLLOW/dir_fdfor the fetched kit, soprovider-input staging remains a Windows gap (fail-closed: it errors out rather
than copying an unvalidated tree).
ctypesplus shell builtins/coreutils.AI-assisted (OpenCode [deepseek-v4.1-flash]); human review required.