guard: close four fail-open holes found by independent review
ci/woodpecker/pr/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
An independent reviewer broke all three new controls before they shipped. Every finding is reproduced as a fixture or a repro, because the class is recurring rather than incidental: each hole was a case where the answer was "allow" because something was ABSENT rather than because it was CHECKED. 1. wrapper-guard read only the spellings it knew. `curl -d@body` (no space), `--request=POST` (equals form), and a URL path assembled from shell variables each carried a real provider write straight through. Write detection now covers every body and method form curl accepts, and the endpoint match no longer anchors on a literal host path that a variable can dissolve. 2. wrapper-guard blocked only when the wrapper FILE existed. A host with a broken or partial install therefore permitted exactly the raw writes the guard exists to stop. Blocking is now on the endpoint; a missing wrapper changes the remedy text, not the verdict — a broken install is not permission to bypass gate 7. 3. mosaic-worktree read a worktree's safety from two questions, and a clean, fully-pushed tree holding a gitignored `local.secret` answered both with zero. `git worktree remove` then deleted the one copy in existence. A file is gitignored precisely so nothing else holds it, so ignored-but-not- disposable files are now a third evidence question. Build junk (node_modules, .venv, dist, caches, *.pyc) stays disposable, so the common case still reads SAFE. 4. check-tools-index counted a documented tool as discoverable at mode 0644. Every caller tests `[ -x ]`, so a non-executable tool is a missing tool; it now fails the gate with its own message. Local gates green: sanitization, resident budget, test enumeration, tools-index (4/4 self-test, 100% on the enforced git suite), and wrapper-guard 20/20.
This commit is contained in:
@@ -31,9 +31,11 @@
|
|||||||
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
||||||
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
||||||
#
|
#
|
||||||
# `rm` and `gc` refuse to delete a worktree with uncommitted changes or with
|
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
|
||||||
# commits absent from every remote. That check is by EVIDENCE, never by size or
|
# commits absent from every remote, or holding ignored files that are not of the
|
||||||
# age. --force overrides it and is yours to type deliberately.
|
# well-known regenerable kind (a `.env` is ignored so it is never committed,
|
||||||
|
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
|
||||||
|
# by size or age. --force overrides it and is yours to type deliberately.
|
||||||
#
|
#
|
||||||
# Run from anywhere inside the repo, or pass --repo <path>.
|
# Run from anywhere inside the repo, or pass --repo <path>.
|
||||||
|
|
||||||
@@ -98,16 +100,41 @@ configuration, credentials, state and caches — not checkouts." ;;
|
|||||||
# Two independent questions, both answered from git, neither from size or age:
|
# Two independent questions, both answered from git, neither from size or age:
|
||||||
# dirty — anything uncommitted in the tree
|
# dirty — anything uncommitted in the tree
|
||||||
# unpushed — commits reachable from HEAD that no remote ref contains
|
# unpushed — commits reachable from HEAD that no remote ref contains
|
||||||
|
# precious — IGNORED files git will not mention and will not miss
|
||||||
|
#
|
||||||
|
# The third question is not obvious and was missed on the first pass. An
|
||||||
|
# independent reviewer demonstrated it in four commands: a pushed, clean
|
||||||
|
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
|
||||||
|
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
|
||||||
|
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
|
||||||
|
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
|
||||||
|
# precisely the files that are ignored BECAUSE they must not be committed, which
|
||||||
|
# is also why nothing else is holding a copy.
|
||||||
|
#
|
||||||
|
# So ignored files count as work unless they are the well-known regenerable
|
||||||
|
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
|
||||||
|
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
|
||||||
|
# an over-narrow one deletes the only copy of a secret (silent, permanent).
|
||||||
|
# The list stays short and conservative for that reason.
|
||||||
|
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
|
||||||
|
|
||||||
wt_dirty() { git -C "$1" status --porcelain 2>/dev/null | head -200 | wc -l; }
|
wt_dirty() { git -C "$1" status --porcelain 2>/dev/null | head -200 | wc -l; }
|
||||||
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || echo "?"; }
|
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || echo "?"; }
|
||||||
|
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
|
||||||
|
# directory entry instead of being enumerated and then discarded.
|
||||||
|
wt_precious() {
|
||||||
|
git -C "$1" status --porcelain --ignored 2>/dev/null \
|
||||||
|
| awk '/^!! /{print substr($0,4)}' \
|
||||||
|
| grep -Ev "$DISPOSABLE_RE" | head -200 | wc -l
|
||||||
|
}
|
||||||
|
|
||||||
wt_state() {
|
wt_state() {
|
||||||
local wt="$1" d u
|
local wt="$1" d u p
|
||||||
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"
|
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
|
||||||
if [ "$d" -eq 0 ] && [ "$u" = "0" ]; then
|
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
|
||||||
printf 'SAFE\tclean; 0 unpushed'
|
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
|
||||||
else
|
else
|
||||||
printf 'PRESERVE\t%s uncommitted; %s unpushed' "$d" "$u"
|
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,14 +207,18 @@ cmd_rm() {
|
|||||||
local path; path="$(derive_path "$branch")"
|
local path; path="$(derive_path "$branch")"
|
||||||
[ -d "$path" ] || die "no worktree at $path"
|
[ -d "$path" ] || die "no worktree at $path"
|
||||||
|
|
||||||
local d u
|
local d u p
|
||||||
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"
|
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
|
||||||
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ]; }; then
|
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
|
||||||
die "refusing to remove $path
|
die "refusing to remove $path
|
||||||
uncommitted files: $d
|
uncommitted files: $d
|
||||||
unpushed commits: $u
|
unpushed commits: $u
|
||||||
Commit and push first — that is the contract. If this work is genuinely
|
ignored, not disposable: $p
|
||||||
disposable, re-run with --force."
|
Commit and push first — that is the contract. Ignored files are counted because
|
||||||
|
git will neither report them nor miss them: a .env or a *.secret is ignored
|
||||||
|
precisely so it is never committed, which is also why nothing else holds a copy.
|
||||||
|
List them with: git -C $path status --porcelain --ignored | grep '^!!'
|
||||||
|
If this work is genuinely disposable, re-run with --force."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ FIXTURES="$TMP/fixtures.tsv"
|
|||||||
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
|
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
|
||||||
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
|
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
|
||||||
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
|
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
|
||||||
|
# --- bypasses an independent reviewer demonstrated against the first version.
|
||||||
|
# Each of these returned 0 (allowed) and each is a real write. They are pinned
|
||||||
|
# as fixtures rather than fixed-and-forgotten because the class is recurring:
|
||||||
|
# the guard reads text, so every spelling it does not know is a hole.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
|
||||||
|
# Reads must survive every one of those broadenings, or the guard gets disabled.
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
|
||||||
} > "$FIXTURES"
|
} > "$FIXTURES"
|
||||||
|
|
||||||
fail=0 n=0
|
fail=0 n=0
|
||||||
|
|||||||
@@ -74,35 +74,68 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- 2/3. provider API writes ---------------------------------------------
|
# ---- 2/3. provider API writes ---------------------------------------------
|
||||||
# Only consider calls that are (a) to a provider API path and (b) mutating.
|
# A raw provider write is four things at once: an HTTP client, a URL, a mutating
|
||||||
is_api=0
|
# verb or a request body, and a path fragment naming an endpoint a wrapper
|
||||||
printf '%s' "$CMD" | grep -Eq '/api/v1/repos/|api\.github\.com/repos/' && is_api=1
|
# already owns. All four are required, which is what keeps reads and unwrapped
|
||||||
if [ "$is_api" -eq 1 ]; then
|
# endpoints flowing.
|
||||||
|
#
|
||||||
|
# Deliberately NOT gated on the literal "/api/v1/repos/". An independent reviewer
|
||||||
|
# broke that version in one line: build the path in shell variables
|
||||||
|
# p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@body "https://host${p}${q}"
|
||||||
|
# and the host-anchored literal never appears, so the check read clean while the
|
||||||
|
# write went through. The endpoint fragments below survive it, because the
|
||||||
|
# fragment has to appear somewhere for the URL to be constructible at all.
|
||||||
|
if printf '%s' "$CMD" | grep -Eq 'curl|wget|http(ie)?[[:space:]]' \
|
||||||
|
&& printf '%s' "$CMD" | grep -Eq 'https?://'; then
|
||||||
|
|
||||||
|
# Write detection. Every spelling curl accepts, because the guard is defeated
|
||||||
|
# by the one spelling it does not know: `-d@body` (no space) and
|
||||||
|
# `--request=POST` (equals form) both slipped past the first version.
|
||||||
is_write=0
|
is_write=0
|
||||||
printf '%s' "$CMD" | grep -Eq -- '-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
# curl sends POST implicitly when given a body.
|
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
||||||
printf '%s' "$CMD" | grep -Eq -- '--data|-d[[:space:]]' && is_write=1
|
# curl sends POST implicitly when handed a body, in any of these forms.
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file' && is_write=1
|
||||||
|
|
||||||
if [ "$is_write" -eq 1 ]; then
|
if [ "$is_write" -eq 1 ]; then
|
||||||
endpoint=""; wrapper=""
|
endpoint=""; wrapper=""
|
||||||
case "$CMD" in
|
case "$CMD" in
|
||||||
*"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*)
|
*"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*)
|
||||||
endpoint="pull-request review"; wrapper="$W/pr-review.sh" ;;
|
endpoint="pull-request review"; wrapper="pr-review.sh" ;;
|
||||||
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="$W/pr-merge.sh" ;;
|
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;;
|
||||||
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="$W/issue-comment.sh" ;;
|
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;;
|
||||||
*"/pulls"*) endpoint="pull request"; wrapper="$W/pr-create.sh" ;;
|
*"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;;
|
||||||
*"/issues"*) endpoint="issue"; wrapper="$W/issue-create.sh" ;;
|
*"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;;
|
||||||
*"/milestones"*) endpoint="milestone"; wrapper="$W/milestone-create.sh" ;;
|
*"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [ -n "$wrapper" ] && [ -x "$wrapper" ]; then
|
# Block on the ENDPOINT, never on whether the wrapper file happens to exist.
|
||||||
|
# The previous version required `[ -x "$W/$wrapper" ]`, which meant a host
|
||||||
|
# with a broken or absent install allowed exactly the raw writes the guard
|
||||||
|
# exists to stop — an absence-driven allow, and the second one found in this
|
||||||
|
# file. A missing wrapper is a broken install; it is not a licence to bypass
|
||||||
|
# gate 7. Say so, and say which is which.
|
||||||
|
if [ -n "$endpoint" ]; then
|
||||||
|
if [ -x "$W/$wrapper" ]; then
|
||||||
|
remedy="Use the wrapper the Constitution (gate 7) requires:
|
||||||
|
|
||||||
|
$W/$wrapper
|
||||||
|
|
||||||
|
Run \`$wrapper --help\` for the flags."
|
||||||
|
else
|
||||||
|
remedy="The wrapper that covers this endpoint is \`$wrapper\`, and it is NOT
|
||||||
|
present or not executable at:
|
||||||
|
|
||||||
|
$W/$wrapper
|
||||||
|
|
||||||
|
That is a broken or incomplete install, not permission to send the call raw.
|
||||||
|
Repair the install (\`mosaic doctor\`) and use the wrapper."
|
||||||
|
fi
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
BLOCKED: raw provider API write to the $endpoint endpoint.
|
BLOCKED: raw provider API write to the $endpoint endpoint.
|
||||||
|
|
||||||
A Mosaic wrapper already covers this and the Constitution (gate 7) requires it
|
$remedy
|
||||||
before any raw provider call:
|
|
||||||
|
|
||||||
$wrapper
|
|
||||||
|
|
||||||
The wrappers are not a formality. They carry provider-dialect differences that
|
The wrappers are not a formality. They carry provider-dialect differences that
|
||||||
raw curl silently gets wrong — Gitea's review event is APPROVED, GitHub's is
|
raw curl silently gets wrong — Gitea's review event is APPROVED, GitHub's is
|
||||||
@@ -110,8 +143,6 @@ APPROVE, and Gitea accepts the wrong one with HTTP 200 while filing the review
|
|||||||
as PENDING. They also resolve identity explicitly, which matters on a host
|
as PENDING. They also resolve identity explicitly, which matters on a host
|
||||||
where the default login is an admin account.
|
where the default login is an admin account.
|
||||||
|
|
||||||
Run \`$(basename "$wrapper") --help\` for the flags.
|
|
||||||
|
|
||||||
If no wrapper flag can express this call, that is a wrapper gap: extend the
|
If no wrapper flag can express this call, that is a wrapper gap: extend the
|
||||||
wrapper. To proceed anyway for a genuine gap, prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
wrapper. To proceed anyway for a genuine gap, prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ run_check() {
|
|||||||
case "$suite" in _*) continue ;; esac
|
case "$suite" in _*) continue ;; esac
|
||||||
|
|
||||||
local total=0 found=0
|
local total=0 found=0
|
||||||
local -a suite_missing=()
|
local -a suite_missing=() suite_noexec=()
|
||||||
for tool in "$dir"*.sh; do
|
for tool in "$dir"*.sh; do
|
||||||
[ -e "$tool" ] || continue
|
[ -e "$tool" ] || continue
|
||||||
base="$(basename -- "$tool")"
|
base="$(basename -- "$tool")"
|
||||||
@@ -159,6 +159,13 @@ run_check() {
|
|||||||
total=$((total + 1))
|
total=$((total + 1))
|
||||||
if documented "$base"; then
|
if documented "$base"; then
|
||||||
found=$((found + 1))
|
found=$((found + 1))
|
||||||
|
# Documented AND present is not enough. The index presents these as
|
||||||
|
# commands to run, and every caller — the wrapper guard included —
|
||||||
|
# decides "is this tool here?" with `[ -x ]`. A 0644 wrapper is
|
||||||
|
# documented, present, and dead: it reads as absent to every check that
|
||||||
|
# matters while scoring 100% here. That is a false green, which is worse
|
||||||
|
# than a red, so it fails rather than warns.
|
||||||
|
[ -x "$tool" ] || suite_noexec+=("$base")
|
||||||
else
|
else
|
||||||
suite_missing+=("$base")
|
suite_missing+=("$base")
|
||||||
fi
|
fi
|
||||||
@@ -166,11 +173,16 @@ run_check() {
|
|||||||
[ "$total" -eq 0 ] && continue
|
[ "$total" -eq 0 ] && continue
|
||||||
|
|
||||||
local pct=$(( found * 100 / total ))
|
local pct=$(( found * 100 / total ))
|
||||||
|
if [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -gt 0 ]; then
|
||||||
|
printf 'FAIL %-12s %3d%% (%d/%d) documented but not executable: %s\n' \
|
||||||
|
"$suite" "$pct" "$found" "$total" "${suite_noexec[*]}"
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
|
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
|
||||||
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
|
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
|
||||||
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
|
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
|
||||||
rc=1
|
rc=1
|
||||||
elif [ "$enforced" -eq 1 ]; then
|
elif [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -eq 0 ]; then
|
||||||
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
|
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
|
||||||
else
|
else
|
||||||
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
|
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
|
||||||
@@ -222,6 +234,7 @@ self_test() {
|
|||||||
mkdir -p "$tmp/tools/git"
|
mkdir -p "$tmp/tools/git"
|
||||||
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
|
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
|
||||||
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
|
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
|
||||||
|
chmod +x "$tmp/tools/git/documented-tool.sh" "$tmp/tools/git/test-ignored.sh"
|
||||||
|
|
||||||
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
|
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
|
||||||
# command-prefix assignment, so point the globals at the fixture directly.
|
# command-prefix assignment, so point the globals at the fixture directly.
|
||||||
@@ -231,18 +244,18 @@ self_test() {
|
|||||||
# Case 1: fully documented -> pass.
|
# Case 1: fully documented -> pass.
|
||||||
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
|
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
|
||||||
if run_check >/dev/null; then
|
if run_check >/dev/null; then
|
||||||
printf 'self-test 1/3 ok (complete index passes)\n'
|
printf 'self-test 1/4 ok (complete index passes)\n'
|
||||||
else
|
else
|
||||||
printf 'self-test 1/3 FAIL (complete index should pass)\n'; return 1
|
printf 'self-test 1/4 FAIL (complete index should pass)\n'; return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Case 2: an undocumented tool -> fail.
|
# Case 2: an undocumented tool -> fail.
|
||||||
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
|
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
|
||||||
rc=0; run_check >/dev/null || rc=$?
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
if [ "$rc" -eq 1 ]; then
|
if [ "$rc" -eq 1 ]; then
|
||||||
printf 'self-test 2/3 ok (undocumented tool fails the gate)\n'
|
printf 'self-test 2/4 ok (undocumented tool fails the gate)\n'
|
||||||
else
|
else
|
||||||
printf 'self-test 2/3 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
|
printf 'self-test 2/4 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Case 3: a stale index reference -> fail.
|
# Case 3: a stale index reference -> fail.
|
||||||
@@ -250,12 +263,26 @@ self_test() {
|
|||||||
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
|
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
|
||||||
rc=0; run_check >/dev/null || rc=$?
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
if [ "$rc" -eq 1 ]; then
|
if [ "$rc" -eq 1 ]; then
|
||||||
printf 'self-test 3/3 ok (stale index reference fails the gate)\n'
|
printf 'self-test 3/4 ok (stale index reference fails the gate)\n'
|
||||||
else
|
else
|
||||||
printf 'self-test 3/3 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
|
printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '\nself-test passed: the gate demonstrably reds on both drift directions.\n'
|
# Case 4: documented, present, and NOT executable -> fail. Found by an
|
||||||
|
# independent reviewer: a 0644 wrapper scored 100% here while reading as
|
||||||
|
# absent to every `[ -x ]` in the fleet, including the wrapper guard's.
|
||||||
|
sed -i '/deleted-tool/d' "$tmp/doc.md"
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/noexec-tool.sh"
|
||||||
|
chmod 0644 "$tmp/tools/git/noexec-tool.sh"
|
||||||
|
printf 'and tools/git/noexec-tool.sh\n' >> "$tmp/doc.md"
|
||||||
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
printf 'self-test 4/4 ok (documented but non-executable tool fails the gate)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 4/4 FAIL (non-executable tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nself-test passed: the gate demonstrably reds on every drift direction.\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$SELF_TEST" -eq 1 ]; then
|
if [ "$SELF_TEST" -eq 1 ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user