feat(pre-push-safety-net): preserve existing hooks during explicit integration

This commit is contained in:
Marius Ciepluch
2026-09-13 17:51:28 +02:00
parent f04955c367
commit 749c7ab45d
9 changed files with 294 additions and 14 deletions
@@ -35,3 +35,43 @@ deletions, no work, policy combinations and deadline ownership.
- CLAIM: Task 7.1 interface and policy integration implemented. - CLAIM: Task 7.1 interface and policy integration implemented.
- EVIDENCE: `/tmp/group7-1-full.log`, `/tmp/group7-1-final-focused.log`; ruff, mypy and diff checks exit 0. - EVIDENCE: `/tmp/group7-1-full.log`, `/tmp/group7-1-final-focused.log`; ruff, mypy and diff checks exit 0.
- GAPS: Representative multi-ref performance and real capability admission remain group 8 work. - GAPS: Representative multi-ref performance and real capability admission remain group 8 work.
## Task 7.2 brief
Requirements 1.2, 5.3, 5.4 and 7.4; design: Result and Hook Adapter.
Deliver executable opt-in integration and non-overwriting installation, preserve the
configured hook path, explicitly chain a prior hook with its exact input/arguments,
retain rejection, and document guarded removal. The existing hook's runtime remains
its own behavior; input retention and Safety Net analysis share one deadline.
Verify with disposable repositories and a local bare remote, never this project's hooks.
## Debug Report
- ROOT_CAUSE: `git rev-parse --path-format=absolute --git-path hooks/pre-push` canonicalizes the final symlink, so even O_EXCL on the returned name creates its target. Initial attribution to shell noclobber was not supported by the subsequent test.
- CATEGORY: LOGIC_ERROR
- FIX_PLAN: Resolve only `hooks`, append the literal `pre-push` basename, and create with O_EXCL/O_NOFOLLOW.
- VERIFICATION: Dangling-symlink regression passes; target remains absent.
- NEXT_ACTION: RETRY_TASK
- CONFIDENCE: HIGH
- NOTES: Git documents absolute output as canonical: https://git-scm.com/docs/git-rev-parse/2.33.0.html . Guarded removal instructions use directory-only resolution too.
## Status Report
- STATUS: READY_FOR_REVIEW
- TASK: 7.2
- RED_PHASE_OUTPUT: `/tmp/group7-2-red.log` (missing integration); `/tmp/group7-2-flag-off.log` (two failures with chaining OFF). ON: three passed; switch removed, final 16 hook/interface tests passed.
- TESTS_RUN: full regression 1282 passed, 9 skipped in 119.95s; final 16 focused tests include subsequently added exact-exit and symlink controls. Ruff, formatting, mypy, shell syntax, CLI help and diff checks pass.
- CONCERNS: input-retention failure stops the integration because a prior hook cannot safely receive partial input. Prior hook runtime remains separate from analysis.
## Review Verdict
- VERDICT: APPROVED
- TASK: 7.2
- MECHANICAL_RESULTS: actual diff and installation/removal recipes reviewed; disposable remote accepts/rejects correctly; original stdin bytes/arguments and exact nonzero exit preserved; source/index/ref state and core.hooksPath preserved; no placeholders or secrets; task boundary maintained.
- FINDINGS: final-path canonicalization issue repaired and regression tested. Installation is explicit and does not overwrite existing hooks or symlink targets.
- RED phase: VERIFIED.
- SUMMARY: Opt-in integration preserves prior hook behavior and supports guarded restoration.
## Verification Result
- STATUS: VERIFIED
- CLAIM_TYPE: TASK
- CLAIM: Task 7.2 non-destructive hook integration delivered.
- EVIDENCE: `/tmp/group7-2-full.log`, `/tmp/group7-2-final-focused.log`; static checks and shell/CLI smoke exit 0.
- GAPS: No hook installed in this project's actual repository; rollout remains NO-GO.
+4 -3
View File
@@ -19,7 +19,7 @@
} }
}, },
"ready_for_implementation": true, "ready_for_implementation": true,
"implementation_approval_note": "Group 7 authorized as a whole; 7.1 verified, 7.2\u20137.3 in progress. Default capability registry empty; rollout NO-GO.", "implementation_approval_note": "Group 7 authorized; 7.1\u20137.2 verified, 7.3 next. Default capability registry empty; rollout NO-GO.",
"depends_on": [ "depends_on": [
"contributor-scan" "contributor-scan"
], ],
@@ -108,9 +108,10 @@
"6.2", "6.2",
"6.3", "6.3",
"6.4", "6.4",
"7.1" "7.1",
"7.2"
], ],
"remaining_tasks": 6, "remaining_tasks": 5,
"verification_record": "implementation-group-7.md", "verification_record": "implementation-group-7.md",
"verification_records": [ "verification_records": [
"implementation-group-1.md", "implementation-group-1.md",
+1 -1
View File
@@ -198,7 +198,7 @@ independent capability qualification. Groups 7–8 remain pending; rollout remai
- _Boundary: CLI/config, Push runner and Result and Hook Adapter integration_ - _Boundary: CLI/config, Push runner and Result and Hook Adapter integration_
- _Depends: 2.1, 4.2, 4.3, 6.4_ - _Depends: 2.1, 4.2, 4.3, 6.4_
- _Requirements: 1.1, 1.3, 1.4, 4.1, 5.1, 5.2, 5.3, 5.4, 5.5, 7.1_ - _Requirements: 1.1, 1.3, 1.4, 4.1, 5.1, 5.2, 5.3, 5.4, 5.5, 7.1_
- [ ] 7.2 Provide non-destructive hook integration and removal - [x] 7.2 Provide non-destructive hook integration and removal
- Supply an executable integration snippet/installation path with explicit invocation, preserving existing hooks, hook path configuration, remote arguments and stdin for other consumers. - Supply an executable integration snippet/installation path with explicit invocation, preserving existing hooks, hook path configuration, remote arguments and stdin for other consumers.
- Refuse automatic overwrite; retain an existing hook's nonzero result even when the Safety Net runs advisory. Include concrete integration/removal instructions with the delivered hook interface. - Refuse automatic overwrite; retain an existing hook's nonzero result even when the Safety Net runs advisory. Include concrete integration/removal instructions with the delivered hook interface.
- Done when a disposable Git remote accepts/rejects pushes correctly with an existing stdin-reading hook, and removal restores the preceding behavior without touching source or refs. - Done when a disposable Git remote accepts/rejects pushes correctly with an existing stdin-reading hook, and removal restores the preceding behavior without touching source or refs.
+62
View File
@@ -282,3 +282,65 @@ is separate from alert eligibility: current admission is always applied. No hook
by replay. Artifact timings record discovery, graph and query hits; identical-tip hits do by replay. Artifact timings record discovery, graph and query hits; identical-tip hits do
not establish representative changed-code latency. Corrupt, partial or incompatible entries not establish representative changed-code latency. Corrupt, partial or incompatible entries
are misses, with remaining work constrained by the same push deadline. are misses, with remaining work constrained by the same push deadline.
### Experimental pre-push integration
The default capability registry is empty pending independent qualification. Installing
this interface does not make the project rollout-ready. Advisory is the default;
blocking and strict incomplete-coverage handling are separate explicit choices.
Put the installed `ousast` executable on the Git process's PATH. From this tool's
checkout, explicitly install into the desired repository:
```sh
ops/install-pre-push /absolute/path/to/repository
```
The installer resolves Git's effective hook directory (including `core.hooksPath`),
leaves that configuration untouched, and refuses to overwrite any existing hook.
For an existing hook, inspect it and explicitly move it aside **before** installation:
```sh
hook_dir=$(git -C /absolute/path/to/repository rev-parse --path-format=absolute --git-path hooks)
hook="$hook_dir/pre-push"
test ! -e "$hook.before-ousast" && test ! -L "$hook.before-ousast" && mv -- "$hook" "$hook.before-ousast"
ops/install-pre-push /absolute/path/to/repository
```
The wrapper chains that sibling hook with the exact remote arguments and original
stdin bytes, even when analysis blocks. A prior rejection always remains a rejection.
Only an explicitly chained hook executes project-owned hook code; analysis executes
no project code. The existing hook retains its own runtime, outside the safety-net
analysis deadline. Integration input retention is bounded to 1 MiB and the analysis
deadline; failure to retain complete input stops the integration rather than feeding
partial input to another consumer.
Artifacts default to `$XDG_CACHE_HOME/openultrasast/push` (or `~/.cache/openultrasast/push`).
Set `OUSAST_ARTIFACT_DIR` to an absolute directory outside the repository if needed.
`OUSAST_PUSH_DEADLINE` defaults to 30 seconds. `OUSAST_PUSH_MODE=blocking` opts into
blocking; `OUSAST_INCOMPLETE_COVERAGE=block` additionally blocks incomplete coverage
in that mode. These variables do not enable detector capabilities or model calls.
For a new branch, add an explicit `--comparison-base <local-ref>` to both command
branches of the installed wrapper if a suitable local base exists. Without it, a new
branch records missing-base coverage rather than silently selecting HEAD or fetching.
Direct stdin integration is `ousast pre-push . --artifact /outside/push.json
--remote "$1" "$2"`; explicit `--base` and `--head` replay remains available.
To remove, inspect the installed wrapper first. If it is unchanged, this guarded
recipe removes only the supplied wrapper and restores the previous hook:
```sh
hook_dir=$(git -C /absolute/path/to/repository rev-parse --path-format=absolute --git-path hooks)
hook="$hook_dir/pre-push"
if cmp -s ops/pre-push "$hook"; then
rm -- "$hook"
if test -e "$hook.before-ousast" || test -L "$hook.before-ousast"; then
mv -n -- "$hook.before-ousast" "$hook"
fi
else
printf '%s\n' 'Wrapper differs: inspect and remove your integration changes manually.'
fi
```
Neither installation nor removal modifies source, the index, refs, or hook-path
configuration. Keep any prior hook backup until restoration is confirmed.
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Explicit installation only. Never edit core.hooksPath or overwrite a hook.
set -eu
repo=${1:-.}
source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
hook_dir=$(git -C "$repo" rev-parse --path-format=absolute --git-path hooks)
hook="$hook_dir/pre-push"
mkdir -p -- "$(dirname -- "$hook")"
# Resolve only the directory: Git canonicalizes a final hook symlink to its target.
# O_EXCL refuses every pre-existing final path, including dangling symlinks.
python3 - "$source_dir/pre-push" "$hook" <<'PY'
import os
import shutil
import sys
try:
with open(sys.argv[1], 'rb') as source:
fd = os.open(sys.argv[2], os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o755)
with os.fdopen(fd, 'wb') as target:
shutil.copyfileobj(source, target)
os.fchmod(target.fileno(), 0o755)
except OSError:
print('Installation refused: hook exists or cannot be created. See ops/README.md.', file=sys.stderr)
sys.exit(1)
PY
printf 'Installed %s\n' "$hook"
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/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.
artifact_dir=${OUSAST_ARTIFACT_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openultrasast/push}
prior="$0.before-ousast"
if [ -e "$prior" ] || [ -L "$prior" ]; then
exec ousast 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" \
--deadline "${OUSAST_PUSH_DEADLINE:-30}" \
--mode "${OUSAST_PUSH_MODE:-advisory}" \
--incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" \
--remote "$1" "$2"
+36 -9
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import subprocess
import tempfile import tempfile
import time
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass, replace from dataclasses import asdict, dataclass, replace
from pathlib import Path from pathlib import Path
@@ -117,6 +119,7 @@ def main(argv: list[str] | None = None) -> int:
replay_parser.add_argument("--head") replay_parser.add_argument("--head")
replay_parser.add_argument("--remote", nargs=2, metavar=("NAME", "URL")) replay_parser.add_argument("--remote", nargs=2, metavar=("NAME", "URL"))
replay_parser.add_argument("--comparison-base") replay_parser.add_argument("--comparison-base")
replay_parser.add_argument("--prior-hook", type=Path, help="explicitly chain an existing hook with the same input and arguments")
replay_parser.add_argument("--artifact", type=Path, required=True) replay_parser.add_argument("--artifact", type=Path, required=True)
replay_parser.add_argument( replay_parser.add_argument(
"--cache-dir", type=Path, help="reuse compatible local artifacts in a private directory outside the repository" "--cache-dir", type=Path, help="reuse compatible local artifacts in a private directory outside the repository"
@@ -221,21 +224,45 @@ def main(argv: list[str] | None = None) -> int:
incomplete_coverage_policy=args.incomplete_coverage, incomplete_coverage_policy=args.incomplete_coverage,
) )
options: dict[str, Any] = dict(artifact=args.artifact, config=settings, max_regions=args.max_regions, cache_dir=args.cache_dir) options: dict[str, Any] = dict(artifact=args.artifact, config=settings, max_regions=args.max_regions, cache_dir=args.cache_dir)
if args.base and args.prior_hook:
parser.error("--prior-hook requires Git stdin mode")
prior_data = None
if args.base: if args.base:
delivery = replay(args.path, base=args.base, head=args.head, **options) delivery = replay(args.path, base=args.base, head=args.head, **options)
else: else:
# multiprocessing closes sys.stdin in its child; duplicate the Git pipe first. # multiprocessing closes sys.stdin in its child; duplicate the Git pipe first.
with os.fdopen(os.dup(0), encoding="utf-8", errors="strict") as stream: with os.fdopen(os.dup(0), "rb") as stream:
delivery = push( if args.prior_hook:
args.path, from .model.contracts import ExecutionBudget
updates=lambda: stream.read(1024 * 1024 + 1), from .push.runner import _prepare
remote_name=args.remote[0],
remote_url=args.remote[1], shared = ExecutionBudget(time.monotonic() + settings.deadline_seconds, settings.cancellation_allowance_seconds)
**options, try:
) prior_data = _prepare(lambda: stream.read(1024 * 1024 + 1), shared)
if len(prior_data) > 1024 * 1024:
raise ValueError("input exceeds hook integration limit")
except (ValueError, RuntimeError, TimeoutError):
print("Hook input could not be retained for all consumers. Retry with complete input and sufficient time.")
return 1
options["execution_budget"] = shared
def updates() -> str:
return (prior_data if prior_data is not None else stream.read(1024 * 1024 + 1)).decode("utf-8")
delivery = push(args.path, updates=updates, remote_name=args.remote[0], remote_url=args.remote[1], **options)
except ValueError as error: except ValueError as error:
parser.error(str(error)) parser.error(str(error))
print(delivery.text, end="") print(delivery.text, end="", flush=True)
if args.prior_hook and prior_data is not None:
# This is the explicitly chained hook's own behavior, outside analysis.
# Never replace its rejection with our advisory success or impose our timeout on it.
try:
prior = subprocess.run([str(args.prior_hook.resolve()), *args.remote], input=prior_data, cwd=args.path)
except OSError:
print("Existing hook could not be started. Restore its executable path before retrying.")
return 1
if prior.returncode:
return prior.returncode if prior.returncode > 0 else 128 - prior.returncode
return delivery.exit_code return delivery.exit_code
if args.command == "scan": if args.command == "scan":
return _scan(args.path, args.config, args.mode, args.fail_on) return _scan(args.path, args.config, args.mode, args.fail_on)
+2 -1
View File
@@ -405,6 +405,7 @@ def push(
backend: Any = None, backend: Any = None,
max_regions: int = 500, max_regions: int = 500,
cache_dir: Path | None = None, cache_dir: Path | None = None,
execution_budget: ExecutionBudget | None = None,
) -> ReportDelivery: ) -> ReportDelivery:
"""Consume one Git transaction. Resolution, every comparison and reporting share a deadline.""" """Consume one Git transaction. Resolution, every comparison and reporting share a deadline."""
config = config or PushConfig() config = config or PushConfig()
@@ -413,7 +414,7 @@ def push(
if cache_dir is not None and cache_dir.resolve().is_relative_to(repository.resolve()): if cache_dir is not None and cache_dir.resolve().is_relative_to(repository.resolve()):
raise ValueError("cache directory must be outside the analyzed repository") raise ValueError("cache directory must be outside the analyzed repository")
started = time.monotonic() started = time.monotonic()
budget = ExecutionBudget(started + config.deadline_seconds, config.cancellation_allowance_seconds) budget = execution_budget or ExecutionBudget(started + config.deadline_seconds, config.cancellation_allowance_seconds)
reports: list[PushReport] = [] reports: list[PushReport] = []
analyses: list[ComparisonAnalysis] = [] analyses: list[ComparisonAnalysis] = []
reasons: list[str] = [] reasons: list[str] = []
+107
View File
@@ -0,0 +1,107 @@
"""Real local Git pushes; never install in the project being developed."""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from test_push_runner import history
@pytest.mark.parametrize("prior_exit", [0, 7])
def test_existing_hook_stdin_remote_exit_and_removal(tmp_path, prior_exit):
root, base, 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 / ".custom-hooks"
hooks.mkdir()
git("config", "core.hooksPath", ".custom-hooks")
hook = hooks / "pre-push"
prior = hooks / "pre-push.before-ousast"
log = tmp_path / "prior-input"
args = tmp_path / "prior-args"
prior.write_text(f'#!/bin/sh\nprintf "%s\\n" "$@" > "{args}"\ncat > "{log}"\nexit {prior_exit}\n')
prior.chmod(0o755)
shutil.copyfile(Path(__file__).resolve().parents[1] / "ops/pre-push", hook)
hook.chmod(0o755)
before = (root / ".git/index").read_bytes(), (root / "api.js").read_bytes(), git("rev-parse", "HEAD")
env = {
**os.environ,
"PATH": str(Path(".venv/bin").resolve()) + os.pathsep + os.environ["PATH"],
"OUSAST_ARTIFACT_DIR": str(tmp_path / "artifacts"),
"OUSAST_PUSH_DEADLINE": "0.1",
}
result = subprocess.run(["git", "-C", str(root), "push", "origin", "HEAD:refs/heads/test"], env=env, capture_output=True)
assert (result.returncode == 0) == (prior_exit == 0), result.stderr
assert log.read_text() == f"HEAD {head} refs/heads/test {'0' * 40}\n"
assert args.read_text().splitlines() == ["origin", str(remote)]
assert ((root / ".git/index").read_bytes(), (root / "api.js").read_bytes(), git("rev-parse", "HEAD")) == before
assert git("config", "core.hooksPath") == ".custom-hooks"
assert list((tmp_path / "artifacts").glob("*.json"))
# Explicit removal restores the prior hook byte-for-byte, without config/source/ref edits.
original = prior.read_bytes()
hook.unlink()
prior.rename(hook)
assert hook.read_bytes() == original
result = subprocess.run(["git", "-C", str(root), "push", "origin", "HEAD:refs/heads/after-removal"], env=env, capture_output=True)
assert (result.returncode == 0) == (prior_exit == 0)
assert "refs/heads/after-removal" in log.read_text()
def test_install_refuses_existing_and_honors_hooks_path(tmp_path):
root, _, _, git = history(tmp_path)
git("config", "core.hooksPath", ".custom-hooks")
installer = Path(__file__).resolve().parents[1] / "ops/install-pre-push"
first = subprocess.run([str(installer), str(root)], capture_output=True)
assert first.returncode == 0, first.stderr
hook = root / ".custom-hooks/pre-push"
hook.write_text("#!/bin/sh\nexit 23\n")
before = hook.read_bytes()
second = subprocess.run([str(installer), str(root)], capture_output=True)
assert second.returncode != 0
assert hook.read_bytes() == before
assert git("config", "core.hooksPath") == ".custom-hooks"
def test_chain_preserves_exact_nonzero_even_for_advisory(tmp_path):
import sys
root, _, _, _ = history(tmp_path)
prior = tmp_path / "prior"
prior.write_text("#!/bin/sh\ncat >/dev/null\nexit 23\n")
prior.chmod(0o755)
result = subprocess.run(
[
sys.executable,
"-m",
"openultrasast.cli",
"pre-push",
str(root),
"--artifact",
str(tmp_path / "result.json"),
"--prior-hook",
str(prior),
"--remote",
"origin",
"/tmp/remote",
],
input=b"",
capture_output=True,
timeout=10,
)
assert result.returncode == 23
def test_install_refuses_dangling_symlink(tmp_path):
root, _, _, git = history(tmp_path)
git("config", "core.hooksPath", ".custom-hooks")
hooks = root / ".custom-hooks"
hooks.mkdir()
target = tmp_path / "must-not-create"
(hooks / "pre-push").symlink_to(target)
installer = Path(__file__).resolve().parents[1] / "ops/install-pre-push"
result = subprocess.run([str(installer), str(root)], capture_output=True)
assert result.returncode != 0
assert not target.exists()