mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
pre-push(17.1): never block a push the user did not choose to block
The installed hook exits 0 with one line when ousast (or OUSAST_COMMAND) is not on PATH, still chaining a prior hook. In Git hook mode without an explicit --mode blocking, argument/usage errors (an invalid OUSAST_PUSH_DEADLINE, a cache directory inside the repository, a refused experimental flag) and unexpected exceptions print one "check did not run" line and exit 0. Blocking mode keeps its usage errors. Requirement 9.1. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a656fda2f4
commit
bf07cd4d1d
@@ -624,10 +624,11 @@ Preserve all completed task evidence below.
|
||||
- **Named by the 2026-10-02 operational-readiness audit**, maintainer-approved as the first increment. The maintainer's definition: a user installs the hook on ANY repository and gets honest, advisory-only results; nothing ever blocks a push. The audit found the plumbing sound (snapshots, resolution, deadline accounting, advisory exit 0, the JSON artifact) and five things in the way: the hook can block a push, quick rules are not part of pre-push, the engine cannot finish a 26-file repository inside 30 s, the terminal hides every skip reason, and there is no supported install path without the source checkout. Before numbers for every item are the audit's live runs on records (Python), serve-static (JavaScript) and unrolled/secure (Go), each with one planted commit.
|
||||
- This group is plumbing and honesty. It enables no capability, edits no rule for a planted sample, and leaves graph reuse (the engine speed work decided in 11.6) as a later task.
|
||||
- _Requirements: 9.1–9.6_
|
||||
- [ ] 17.1 Never block a push the user did not choose to block
|
||||
- [x] 17.1 Never block a push the user did not choose to block
|
||||
- The installed hook exits 0 with one line when `ousast` is missing (the check did not run, and how to install). In hook (stdin) mode, an argument or usage error (an invalid `OUSAST_PUSH_DEADLINE`, a cache directory inside the repository) is advisory: one line and exit 0. `--mode blocking` stays the explicit opt-in that can reject a push, and keeps its usage errors. The prior-hook input-retention failure keeps exit 1, because it is the chained hook's verdict that cannot be obtained, not ours.
|
||||
- Files: `ops/pre-push`, `src/openultrasast/cli.py`. Tests: `tests/test_push_hook.py` (a push with `ousast` absent from PATH succeeds and prints the line; a bad deadline in hook mode exits 0; blocking mode keeps exit 2).
|
||||
- Evidence: the audit's `hook.sh` push 1 (rejected before) and `fail.sh` (rc=2 before) rerun with exit 0.
|
||||
- _Done 2026-10-02:_ the hook prints one line and exits 0 (chaining a prior hook if present) when `ousast` (or `OUSAST_COMMAND`) is missing; in hook mode without `--mode blocking`, argparse/usage errors and unexpected exceptions print one line and exit 0. Tests: `test_missing_ousast_never_blocks_the_push`, `test_hook_mode_usage_errors_are_advisory_unless_blocking` (blocking keeps exit 2), and `test_cli_refuses_the_flag_outside_explicit_replay` updated (the experimental flag is still refused in hook mode, now advisory). Gates: 1987 passed, 42 skipped; ruff, format, mypy clean.
|
||||
- _Requirements: 9.1, 5.3, 5.4_
|
||||
- [ ] 17.2 Name every skipped check in plain words
|
||||
- One terminal line per distinct coverage reason: hex-encoded paths decoded, sha256-suffixed reasons grouped with a count, JSON unchanged. "language not covered: <lang> (<n> files)" for a language with no engine frontend and no quick rules, in both `pre-push` and `scan`. A first push of a new branch says there is no base and what was checked instead. `reports.py` gains lines for the eight scan degradations `cli.py` records and the report did not render (`learning_endpoint_unavailable`, `cpg_unavailable`, `facts_unavailable`, `policy_invalid`, `model_withheld_execution_budget`, `max_findings_exceeded`, `hunter_model_unavailable`, `sandbox_unavailable`).
|
||||
|
||||
+15
-4
@@ -1,16 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Explicitly copy using the non-overwriting recipe in ops/README.md.
|
||||
# A prior hook is chained only after you explicitly move it to this sibling path.
|
||||
# OpenUltraSAST pre-push safety net. Advisory: nothing here rejects a push unless
|
||||
# OUSAST_PUSH_MODE=blocking is set explicitly. Install with `ousast pre-push install`
|
||||
# (or ops/install-pre-push from a checkout); remove with `ousast pre-push uninstall`.
|
||||
# A prior hook is chained only after it was moved to the sibling path "$0.before-ousast".
|
||||
artifact_dir=${OUSAST_ARTIFACT_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openultrasast/push}
|
||||
prior="$0.before-ousast"
|
||||
command=${OUSAST_COMMAND:-ousast}
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
echo "ousast: pre-push check did not run: '$command' is not on PATH." \
|
||||
"Install it with 'pip install openultrasast' or set OUSAST_COMMAND. Push continues."
|
||||
if [ -e "$prior" ] || [ -L "$prior" ]; then
|
||||
exec "$prior" "$@"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [ -e "$prior" ] || [ -L "$prior" ]; then
|
||||
exec ousast pre-push . --artifact "$artifact_dir/push-$$.json" \
|
||||
exec "$command" pre-push . --artifact "$artifact_dir/push-$$.json" \
|
||||
--deadline "${OUSAST_PUSH_DEADLINE:-30}" \
|
||||
--mode "${OUSAST_PUSH_MODE:-advisory}" \
|
||||
--incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" \
|
||||
--prior-hook "$prior" --remote "$1" "$2"
|
||||
fi
|
||||
exec ousast pre-push . --artifact "$artifact_dir/push-$$.json" \
|
||||
exec "$command" pre-push . --artifact "$artifact_dir/push-$$.json" \
|
||||
--deadline "${OUSAST_PUSH_DEADLINE:-30}" \
|
||||
--mode "${OUSAST_PUSH_MODE:-advisory}" \
|
||||
--incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" \
|
||||
|
||||
+77
-43
@@ -339,6 +339,20 @@ def _main(argv: list[str] | None) -> int:
|
||||
"--qualify-population", action="append", default=[], metavar="NAME", help="with --loop: a population whose rows never propose"
|
||||
)
|
||||
|
||||
raw = list(argv if argv is not None else sys.argv[1:])
|
||||
if _advisory_hook(raw):
|
||||
# Git hook mode (stdin) without an explicit blocking opt-in: a check that cannot run must
|
||||
# never reject the push. Say so in one line and allow it (Requirement 9.1).
|
||||
try:
|
||||
return _hook_main(parser, argv)
|
||||
except SystemExit as stop:
|
||||
if stop.code in (0, None):
|
||||
raise
|
||||
print("ousast: pre-push check did not run: invalid arguments or settings (message above). Push continues.")
|
||||
return 0
|
||||
except Exception as error: # noqa: BLE001 -- an advisory hook reports a crash, it never blocks on one
|
||||
print(f"ousast: pre-push check did not run: unexpected {type(error).__name__}. Push continues.")
|
||||
return 0
|
||||
args = parser.parse_args(argv)
|
||||
# Diagnosis needs the stage costs, and nothing configures logging, so the default root level of
|
||||
# WARNING silently dropped every informational line. A run that reports nothing is
|
||||
@@ -347,6 +361,69 @@ def _main(argv: list[str] | None) -> int:
|
||||
_configure_logging()
|
||||
start_profiling() # only when OUSAST_SAMPLE_PROFILE names a file
|
||||
if args.command == "pre-push":
|
||||
return _pre_push(args, parser)
|
||||
if args.command == "scan":
|
||||
return _scan(args.path, args.config, args.mode, args.fail_on)
|
||||
if args.command == "benchmark":
|
||||
return _benchmark(args.manifest, args.config, args.mode)
|
||||
if args.command == "improve":
|
||||
return _improve(
|
||||
args.manifest,
|
||||
max_rounds=args.max_rounds,
|
||||
recall_floor=args.recall_floor,
|
||||
fp_ceiling=args.fp_ceiling,
|
||||
ledger=args.ledger,
|
||||
journal=args.journal,
|
||||
ruleset_dir=args.ruleset_dir,
|
||||
dry_run=args.dry_run,
|
||||
pair_catalog=None if args.no_pair_gate else args.pair_catalog,
|
||||
profile_tolerance=args.profile_tolerance,
|
||||
min_holdout_pairs=args.min_holdout_pairs,
|
||||
memory=args.memory,
|
||||
qualify_populations=tuple(args.qualify_population),
|
||||
guard_catalog=args.pair_catalog,
|
||||
)
|
||||
if args.command == "pairs":
|
||||
return _pairs(
|
||||
args.catalog,
|
||||
args.slice,
|
||||
json_out=args.json,
|
||||
profile=args.profile,
|
||||
split=args.split,
|
||||
pointers=args.pointers,
|
||||
)
|
||||
if args.command == "repos":
|
||||
return _repos(args.dir, args.repo, fetch=args.fetch, json_out=args.json)
|
||||
if args.command == "model":
|
||||
return _model_candidates(args)
|
||||
if args.command == "plane":
|
||||
return _plane(args)
|
||||
if args.command == "learn":
|
||||
return _learn_labels(args) if args.learn_command == "labels" else run_learn(args)
|
||||
if args.command == "mcp":
|
||||
from .mcp import serve # lazy: keeps the import cycle (mcp -> cli) one-directional
|
||||
|
||||
return serve()
|
||||
return 2
|
||||
|
||||
|
||||
def _advisory_hook(argv: list[str]) -> bool:
|
||||
"""A Git hook invocation (`--remote NAME URL`, stdin input) that did not opt into blocking."""
|
||||
if argv[:1] != ["pre-push"] or "--remote" not in argv:
|
||||
return False
|
||||
blocking = any(a == "--mode=blocking" for a in argv) or any(
|
||||
a == "--mode" and i + 1 < len(argv) and argv[i + 1] == "blocking" for i, a in enumerate(argv)
|
||||
)
|
||||
return not blocking
|
||||
|
||||
|
||||
def _hook_main(parser: argparse.ArgumentParser, argv: list[str] | None) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
_configure_logging()
|
||||
return _pre_push(args, parser)
|
||||
|
||||
|
||||
def _pre_push(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||
from .config import PushConfig
|
||||
from .push.runner import push, replay
|
||||
|
||||
@@ -418,49 +495,6 @@ def _main(argv: list[str] | None) -> int:
|
||||
if prior.returncode:
|
||||
return prior.returncode if prior.returncode > 0 else 128 - prior.returncode
|
||||
return delivery.exit_code
|
||||
if args.command == "scan":
|
||||
return _scan(args.path, args.config, args.mode, args.fail_on)
|
||||
if args.command == "benchmark":
|
||||
return _benchmark(args.manifest, args.config, args.mode)
|
||||
if args.command == "improve":
|
||||
return _improve(
|
||||
args.manifest,
|
||||
max_rounds=args.max_rounds,
|
||||
recall_floor=args.recall_floor,
|
||||
fp_ceiling=args.fp_ceiling,
|
||||
ledger=args.ledger,
|
||||
journal=args.journal,
|
||||
ruleset_dir=args.ruleset_dir,
|
||||
dry_run=args.dry_run,
|
||||
pair_catalog=None if args.no_pair_gate else args.pair_catalog,
|
||||
profile_tolerance=args.profile_tolerance,
|
||||
min_holdout_pairs=args.min_holdout_pairs,
|
||||
memory=args.memory,
|
||||
qualify_populations=tuple(args.qualify_population),
|
||||
guard_catalog=args.pair_catalog,
|
||||
)
|
||||
if args.command == "pairs":
|
||||
return _pairs(
|
||||
args.catalog,
|
||||
args.slice,
|
||||
json_out=args.json,
|
||||
profile=args.profile,
|
||||
split=args.split,
|
||||
pointers=args.pointers,
|
||||
)
|
||||
if args.command == "repos":
|
||||
return _repos(args.dir, args.repo, fetch=args.fetch, json_out=args.json)
|
||||
if args.command == "model":
|
||||
return _model_candidates(args)
|
||||
if args.command == "plane":
|
||||
return _plane(args)
|
||||
if args.command == "learn":
|
||||
return _learn_labels(args) if args.learn_command == "labels" else run_learn(args)
|
||||
if args.command == "mcp":
|
||||
from .mcp import serve # lazy: keeps the import cycle (mcp -> cli) one-directional
|
||||
|
||||
return serve()
|
||||
return 2
|
||||
|
||||
|
||||
def _scan(path: Path, config_path: Path, mode: str, fail_on: str) -> int:
|
||||
|
||||
@@ -105,3 +105,42 @@ def test_install_refuses_dangling_symlink(tmp_path):
|
||||
result = subprocess.run([str(installer), str(root)], capture_output=True)
|
||||
assert result.returncode != 0
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_missing_ousast_never_blocks_the_push(tmp_path):
|
||||
"""17.1 / Req 9.1: a hook whose executable is absent allows the push and says the check did not run."""
|
||||
root, _, head, git = history(tmp_path)
|
||||
remote = tmp_path / "remote.git"
|
||||
subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True)
|
||||
git("remote", "add", "origin", str(remote))
|
||||
hooks = root / ".hooks"
|
||||
hooks.mkdir()
|
||||
git("config", "core.hooksPath", ".hooks")
|
||||
shutil.copyfile(Path(__file__).resolve().parents[1] / "ops/pre-push", hooks / "pre-push")
|
||||
(hooks / "pre-push").chmod(0o755)
|
||||
env = {**os.environ, "OUSAST_COMMAND": "ousast-not-installed-anywhere", "OUSAST_ARTIFACT_DIR": str(tmp_path / "a")}
|
||||
result = subprocess.run(["git", "-C", str(root), "push", "origin", "HEAD:refs/heads/main"], env=env, capture_output=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert b"pre-push check did not run" in result.stdout + result.stderr
|
||||
assert git("ls-remote", str(remote)).startswith(head)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extra", "expected"),
|
||||
[
|
||||
(["--deadline", "abc"], 0),
|
||||
(["--cache-dir", "INSIDE"], 0),
|
||||
(["--deadline", "abc", "--mode", "blocking"], 2),
|
||||
],
|
||||
)
|
||||
def test_hook_mode_usage_errors_are_advisory_unless_blocking(tmp_path, extra, expected):
|
||||
"""17.1 / Req 9.1: invalid settings in hook mode allow the push; explicit blocking keeps exit 2."""
|
||||
import sys
|
||||
|
||||
root, _, _, _ = history(tmp_path)
|
||||
extra = [str(root / "cache") if item == "INSIDE" else item for item in extra]
|
||||
command = [sys.executable, "-m", "openultrasast.cli", "pre-push", str(root), "--artifact", str(tmp_path / "r.json")]
|
||||
result = subprocess.run([*command, *extra, "--remote", "origin", "/tmp/remote"], input=b"", capture_output=True, timeout=60)
|
||||
assert result.returncode == expected, result.stderr
|
||||
if expected == 0:
|
||||
assert b"pre-push check did not run" in result.stdout
|
||||
|
||||
@@ -247,8 +247,28 @@ def test_cli_refuses_the_flag_outside_explicit_replay(tmp_path, capsys):
|
||||
from openultrasast.cli import main
|
||||
|
||||
root, base, head, _ = history(tmp_path)
|
||||
# Hook mode refuses the flag without running anything; since 17.1 (Req 9.1) the refusal is advisory
|
||||
# (exit 0 with a line), because a usage error in a hook must never reject the push.
|
||||
hook = ["pre-push", str(root), "--remote", "origin", "/x", "--artifact", str(tmp_path / "x.json")]
|
||||
refused = main([*hook, "--experimental-record-vetoes"])
|
||||
captured = capsys.readouterr()
|
||||
assert refused == 0 and "requires explicit --base/--head replay" in captured.err
|
||||
assert "pre-push check did not run" in captured.out and not (tmp_path / "x.json").exists()
|
||||
with pytest.raises(SystemExit):
|
||||
main(["pre-push", str(root), "--remote", "origin", "/x", "--artifact", str(tmp_path / "x.json"), "--experimental-record-vetoes"])
|
||||
main(
|
||||
[
|
||||
"pre-push",
|
||||
str(root),
|
||||
"--remote",
|
||||
"origin",
|
||||
"/x",
|
||||
"--artifact",
|
||||
str(tmp_path / "x.json"),
|
||||
"--mode",
|
||||
"blocking",
|
||||
"--experimental-record-vetoes",
|
||||
]
|
||||
)
|
||||
assert "requires explicit --base/--head replay" in capsys.readouterr().err
|
||||
code = main(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user