Files
Zecheng Zhang 325e8d33a2 fix(permissions): score the entry that matched, and read entries by the same law
Codex review on #876, four findings, all reproduced first.

- A rule was scored by its deepest path entry, so an entry that says
  nothing about the operand lent it depth: an ask on /repo/* and
  /else/very/deep/* outranked a deny anchored at /repo/private/* and an
  approval reopened it. match_rule now reports the depth of the entry
  that actually matched.
- io_refusal read every deny before any ask while decide ordered by
  anchor depth, so a line admitted under an approved deeper ask was
  refused at every entry the carve-out was written for. Both gates now
  share better_match.
- with_inline returned early when no base role resolved, so an inline
  allow list was accepted when a workspace happened to declare no
  default role. refuse_allow is called on both paths.
- PolicyError is not a ValueError, so an unknown profile name left the
  sessions router as a 500 instead of a 422.

anchor_depth moves beside is_glob in utils/hidden, since both gates need
it and rule sits below decide.

Also: parity.sh still asserted the old model (naming a mount confines
the session), which is what turned CI red; it now pins that an unnamed
mount keeps its own mode and that exclusion is a hide. Drops a dead
"not allowed to access mount" row from the TS fuse errno table, whose
error class this branch removed.
2026-08-20 17:06:12 -07:00

289 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Cross-language CLI feature parity. Runs the SAME battery of commands on the
# Python CLI and the TypeScript CLI, records normalized results per language,
# then asserts (1) the two languages produce identical results and (2) the
# results match the expected values. Covers subshell isolation, sessions,
# per-session mount modes, git-backed versioning, and fuse config wiring.
# Uses RAM mounts only (no redis/minio/fuse kernel deps), so it runs
# anywhere both CLIs are built.
#
# Usage: parity.sh "<py-cli>" "<ts-cli>"
set -uo pipefail
PY_CLI="${1:?python mirage cli command}"
TS_CLI="${2:?typescript mirage cli command}"
HERE="$(cd "$(dirname "$0")" && pwd)"
fail=0
YAML=/tmp/parity-ws.yaml
cat > "$YAML" <<'YML'
mounts:
/:
resource: ram
mode: WRITE
YML
FUSE_YAML=/tmp/parity-fuse.yaml
cat > "$FUSE_YAML" <<'YML'
mode: WRITE
mounts:
/:
resource: ram
backend: fuse
YML
MODES_YAML=/tmp/parity-modes.yaml
cat > "$MODES_YAML" <<'YML'
mode: WRITE
mounts:
/data:
resource: ram
/side:
resource: ram
/ro:
resource: ram
mode: READ
profiles:
blind:
paths:
hide:
- /side
YML
# The workspace default stays WRITE so the implicit scratch root (always
# granted to every session) cannot satisfy the exec gate; only /data is EXEC.
EXEC_YAML=/tmp/parity-exec.yaml
cat > "$EXEC_YAML" <<'YML'
mode: WRITE
mounts:
/data:
resource: ram
mode: EXEC
YML
freeport() { lsof -ti:8765 2>/dev/null | xargs kill -9 2>/dev/null; sleep 1; }
sout() { jq -r '.stdout // .result.stdout // empty'; }
# Drop `ls -l`'s timestamp column so a row can be compared verbatim; the
# mode, owner, size and `name -> target` are all stable.
no_ls_time() { sed -E 's/ [A-Z][a-z]{2} +[0-9]+ [0-9]{2}:[0-9]{2} / /'; }
serr() { jq -r '.stderr // .result.stderr // empty'; }
sexit() { jq -r '.exit_code // .exitCode // .result.exit_code // empty'; }
verdict() { jq -r 'if (.exit_code // .exitCode // .result.exit_code // 1) == 0 then "allowed" else "denied" end'; }
# Run the full battery against one CLI; emit one "key=value" line per probe.
probe() {
local cli="$1" lang="$2"
export MIRAGE_HOME="/tmp/parity-home-$lang"
rm -rf "$MIRAGE_HOME"
freeport
$cli daemon stop >/dev/null 2>&1 </dev/null || true
sleep 1
$cli workspace delete pw >/dev/null 2>&1 </dev/null || true
$cli workspace create "$YAML" --id pw >/dev/null </dev/null
$cli execute -w pw -c 'mkdir -p /data' </dev/null >/dev/null
# ── subshell: cd / export must not leak; exit + pipe ──
echo "subshell.cd_inner=$($cli execute -w pw -c '(cd /data && pwd)' </dev/null | sout)"
echo "subshell.cd_after=$($cli execute -w pw -c 'pwd' </dev/null | sout)"
echo "subshell.export_inner=$($cli execute -w pw -c '(export FOO=bar; echo $FOO)' </dev/null | sout)"
echo "subshell.export_after=$($cli execute -w pw -c 'echo [$FOO]' </dev/null | sout)"
echo "subshell.false_exit=$($cli execute -w pw -c '(false)' </dev/null | sexit)"
echo "subshell.pipe=$($cli execute -w pw -c '(echo a; echo b) | wc -l' </dev/null | sout)"
echo "subshell.oldpwd_no_leak=$($cli execute -w pw -c '(cd /data && (cd /) && echo $OLDPWD)' </dev/null | sout)"
echo "subshell.func_redef=$($cli execute -w pw -c '(f(){ echo A; }; (f(){ echo B; }); f)' </dev/null | sout)"
echo "subshell.func_no_leak=$($cli execute -w pw -c '(nofn(){ echo x; }); nofn 2>/dev/null || echo gone' </dev/null | sout)"
echo "subshell.positional=$($cli execute -w pw -c '(set -- a b c; (set -- x); echo $#)' </dev/null | sout)"
# ── cwd/env: HOME, PWD, OLDPWD, cd -, tilde (GNU cd + pwd) ──
$cli execute -w pw -c 'echo hi > /data/f.txt' </dev/null >/dev/null
echo "cwd.home_default=$($cli execute -w pw -c 'echo $HOME' </dev/null | sout)"
echo "cwd.pwd_var=$($cli execute -w pw -c '(cd /data && echo $PWD)' </dev/null | sout)"
echo "cwd.oldpwd=$($cli execute -w pw -c '(cd /data && cd / && echo $OLDPWD)' </dev/null | sout)"
echo "cwd.cd_dash=$($cli execute -w pw -c '(cd /data && cd / && cd -)' </dev/null | sout)"
echo "cwd.tilde_pwd=$($cli execute -w pw -c '(export HOME=/data && cd ~ && pwd)' </dev/null | sout)"
echo "cwd.tilde_cat=$($cli execute -w pw -c '(export HOME=/data && cat ~/f.txt)' </dev/null | sout)"
echo "cwd.rel_cat=$($cli execute -w pw -c '(cd /data && cat f.txt)' </dev/null | sout)"
echo "cwd.wc_disp=$($cli execute -w pw -c '(cd /data && wc -l f.txt)' </dev/null | sout)"
# ── sessions: default session persists cwd; -s session is isolated ──
$cli session create pw --id s2 </dev/null >/dev/null
$cli execute -w pw -c 'cd /data' </dev/null >/dev/null
echo "session.default_pwd=$($cli execute -w pw -c 'pwd' </dev/null | sout)"
echo "session.s2_pwd=$($cli execute -w pw -s s2 -c 'pwd' </dev/null | sout)"
# ── symlinks: namespace links follow on read; rm/mv act on the entry ──
$cli execute -w pw -c 'echo sym1 > /data/s.txt && ln -s /data/s.txt /data/link.txt' </dev/null >/dev/null
echo "sym.readlink=$($cli execute -w pw -c 'readlink /data/link.txt' </dev/null | sout)"
echo "sym.cat_follow=$($cli execute -w pw -c 'cat /data/link.txt' </dev/null | sout)"
echo "sym.write_through=$($cli execute -w pw -c 'echo via >> /data/link.txt && tail -n 1 /data/s.txt' </dev/null | sout)"
echo "sym.ls_arrow=$($cli execute -w pw -c 'ls -l /data | grep -- "->"' </dev/null | sout | no_ls_time)"
echo "sym.mv_entry=$($cli execute -w pw -c 'mv /data/link.txt /data/moved.txt && readlink /data/moved.txt' </dev/null | sout)"
echo "sym.rm_entry=$($cli execute -w pw -c 'rm /data/moved.txt; readlink /data/moved.txt' </dev/null | sexit)"
echo "sym.dangle=$($cli execute -w pw -c 'ln -s /data/none /data/d.txt; cat /data/d.txt' </dev/null | sexit)"
echo "sym.eloop=$($cli execute -w pw -c 'ln -s /data/l2 /data/l1 && ln -s /data/l1 /data/l2; cat /data/l1 2>&1' </dev/null | sout)"
$cli workspace delete pw >/dev/null 2>&1 </dev/null || true
# ── session modes: -m /mount:mode narrows a mount for this session ──
# Naming a mount is not an allowlist: a mount the session never names
# keeps its own mode, and narrowing only ever weakens (capped asks for
# write on a READ mount and stays read-only). Excluding a mount is a
# hide, which answers absence, so -p blind is the other half of this.
$cli workspace delete gw >/dev/null 2>&1 </dev/null || true
$cli workspace create "$MODES_YAML" --id gw >/dev/null </dev/null
$cli execute -w gw -c 'echo hello > /data/a.txt' </dev/null >/dev/null
$cli execute -w gw -c 'echo aside > /side/s.txt' </dev/null >/dev/null
$cli session create gw --id reader -m /data:read </dev/null >/dev/null
$cli session create gw --id writer -m /data:rw </dev/null >/dev/null
$cli session create gw --id lister -m /data </dev/null >/dev/null
$cli session create gw --id capped -m /ro:write </dev/null >/dev/null
$cli session create gw --id blind -p blind </dev/null >/dev/null
echo "mode.reader_cat=$($cli execute -w gw -s reader -c 'cat /data/a.txt' </dev/null | sout)"
echo "mode.reader_rm_err=$($cli execute -w gw -s reader -c 'rm /data/a.txt' </dev/null | serr)"
echo "mode.reader_redirect=$($cli execute -w gw -s reader -c 'echo leak > /data/new.txt' </dev/null | verdict)"
echo "mode.reader_side=$($cli execute -w gw -s reader -c 'cat /side/s.txt' </dev/null | sout)"
echo "mode.blind_side_err=$($cli execute -w gw -s blind -c 'cat /side/s.txt' </dev/null | serr)"
echo "mode.writer_write=$($cli execute -w gw -s writer -c 'echo w > /data/w.txt && cat /data/w.txt' </dev/null | sout)"
echo "mode.lister_inherits=$($cli execute -w gw -s lister -c 'echo l > /data/l.txt && cat /data/l.txt' </dev/null | sout)"
echo "mode.capped_ro=$($cli execute -w gw -s capped -c 'echo up > /ro/y.txt' </dev/null | verdict)"
$cli workspace delete gw >/dev/null 2>&1 </dev/null || true
# ── session modes: the exec gate is per session on an EXEC mount ──
$cli workspace delete gx >/dev/null 2>&1 </dev/null || true
$cli workspace create "$EXEC_YAML" --id gx >/dev/null </dev/null
$cli session create gx --id noexec -m /data:write </dev/null >/dev/null
$cli session create gx --id withexec -m /data:rwx </dev/null >/dev/null
echo "mode.exec_denied_err=$($cli execute -w gx -s noexec -c 'python3 -c "print(1)"' </dev/null | serr)"
echo "mode.exec_allowed=$($cli execute -w gx -s withexec -c 'python3 -c "print(1)"' </dev/null | sout)"
$cli workspace delete gx >/dev/null 2>&1 </dev/null || true
# ── versioning: commit/branch/log/clone/checkout/diff (git-backed) ──
$cli workspace delete vw >/dev/null 2>&1 </dev/null || true
$cli workspace create "$YAML" --id vw >/dev/null </dev/null
$cli execute -w vw -c 'echo one > /a.txt' </dev/null >/dev/null
$cli workspace commit vw -m first </dev/null >/dev/null
$cli workspace branch vw feature </dev/null >/dev/null # feature @ first
$cli execute -w vw -c 'echo two > /a.txt' </dev/null >/dev/null
$cli workspace commit vw -m second </dev/null >/dev/null # main @ second
echo "version.log=$($cli workspace log vw </dev/null | jq -r '[.[].message] | join(",")')"
echo "version.branch_log=$($cli workspace log vw -b feature </dev/null | jq -r '[.[].message] | join(",")')"
echo "ws.get_mounts=$($cli workspace get vw </dev/null | jq -r '[.mounts[].prefix] | join(",")')"
echo "ws.list_has_vw=$($cli workspace list </dev/null | jq -r 'if (map(.id) | index("vw")) != null then "yes" else "no" end')"
# clone live state (two), and clone from the first commit (one)
$cli workspace delete vwc >/dev/null 2>&1 </dev/null || true
$cli workspace clone vw --id vwc </dev/null >/dev/null
echo "clone.content=$($cli execute -w vwc -c 'cat /a.txt' </dev/null | sout)"
$cli workspace delete vwc >/dev/null 2>&1 </dev/null || true
local first
first="$($cli workspace log vw </dev/null | jq -r '.[-1].id')"
$cli workspace delete vwa >/dev/null 2>&1 </dev/null || true
$cli workspace clone vw --id vwa --at "$first" </dev/null >/dev/null
echo "clone.at_first=$($cli execute -w vwa -c 'cat /a.txt' </dev/null | sout)"
$cli workspace delete vwa >/dev/null 2>&1 </dev/null || true
$cli workspace checkout vw "$first" </dev/null >/dev/null 2>&1
echo "version.checkout_first=$($cli execute -w vw -c 'cat /a.txt' </dev/null | sout)"
$cli workspace checkout vw main </dev/null >/dev/null 2>&1
$cli execute -w vw -c 'echo three > /a.txt' </dev/null >/dev/null
echo "version.diff=$($cli workspace diff vw </dev/null | jq -rc '{added,modified,deleted}')"
$cli workspace delete vw >/dev/null 2>&1 </dev/null || true
# ── fuse: backend:fuse config is accepted and the workspace operates ──
$cli workspace delete fw >/dev/null 2>&1 </dev/null || true
$cli workspace create "$FUSE_YAML" --id fw >/dev/null </dev/null
echo "fuse.operates=$($cli execute -w fw -c 'echo alive' </dev/null | sout)"
$cli workspace delete fw >/dev/null 2>&1 </dev/null || true
freeport
}
echo "===== probing Python CLI ====="
probe "$PY_CLI" py | sort > /tmp/parity-py.txt
echo "===== probing TypeScript CLI ====="
probe "$TS_CLI" ts | sort > /tmp/parity-ts.txt
echo
echo "===== Python results ====="
cat /tmp/parity-py.txt
echo
echo "===== language parity (py vs ts) ====="
if diff -u /tmp/parity-py.txt /tmp/parity-ts.txt; then
echo " OK Python and TypeScript produced identical results"
else
echo " FAIL Python and TypeScript diverged (see diff above)"
fail=1
fi
echo
echo "===== expected values ====="
expect() {
local key="$1" want="$2"
local got
got="$(grep -F "$key=" /tmp/parity-py.txt | head -1 | cut -d= -f2-)"
if [ "$got" == "$want" ]; then
echo " OK $key == $(printf '%q' "$want")"
else
echo " FAIL $key: got $(printf '%q' "$got") expected $(printf '%q' "$want")"
fail=1
fi
}
expect "subshell.cd_inner" "/data"
expect "subshell.cd_after" "/"
expect "subshell.export_inner" "bar"
expect "subshell.export_after" "[]"
expect "subshell.false_exit" "1"
expect "subshell.pipe" "2"
expect "subshell.oldpwd_no_leak" "/"
expect "subshell.func_redef" "A"
expect "subshell.func_no_leak" "gone"
expect "subshell.positional" "3"
expect "cwd.home_default" ""
expect "cwd.pwd_var" "/data"
expect "cwd.oldpwd" "/data"
expect "cwd.cd_dash" "/data"
expect "cwd.tilde_pwd" "/data"
expect "cwd.tilde_cat" "hi"
expect "cwd.rel_cat" "hi"
expect "cwd.wc_disp" "1 f.txt"
expect "session.default_pwd" "/data"
expect "session.s2_pwd" "/"
expect "mode.reader_cat" "hello"
expect "mode.reader_rm_err" "rm: read-only mount at /data/"
expect "mode.reader_redirect" "denied"
expect "mode.reader_side" "aside"
expect "mode.blind_side_err" "cat: /side/s.txt: No such file or directory"
expect "mode.writer_write" "w"
expect "mode.lister_inherits" "l"
expect "mode.capped_ro" "denied"
expect "mode.exec_denied_err" "python3: root mount '/' is not in EXEC mode"
expect "mode.exec_allowed" "1"
expect "version.log" "second,first"
expect "version.branch_log" "first"
expect "ws.get_mounts" "/"
expect "ws.list_has_vw" "yes"
expect "clone.content" "two"
expect "clone.at_first" "one"
expect "version.checkout_first" "one"
expect 'version.diff' '{"added":[],"modified":["a.txt"],"deleted":[]}'
expect "fuse.operates" "alive"
expect "sym.readlink" "/data/s.txt"
expect "sym.cat_follow" "sym1"
expect "sym.write_through" "via"
# A link row is a real GNU row now: lrwxrwxrwx (a link carries no
# permission bits of its own) and the size is the target's length.
expect "sym.ls_arrow" "lrwxrwxrwx 1 user user 11 link.txt -> /data/s.txt"
expect "sym.mv_entry" "/data/s.txt"
expect "sym.rm_entry" "1"
expect "sym.dangle" "1"
expect "sym.eloop" "cat: /data/l1: Too many levels of symbolic links"
if [ "$fail" != "0" ]; then
echo
echo "CLI feature parity FAILED."
exit 1
fi
echo
echo "CLI feature parity OK (subshell, sessions, session modes, versioning, fuse; py == ts)."