From 33333ac4d9cd77a13249de5dccfdea1c2e6bf3db Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 10 Jun 2026 07:21:57 +0000 Subject: [PATCH 1/4] Supervise gitleaks inline allow exceptions --- bot_bottle/cli/supervise.py | 43 +++++++-- bot_bottle/git_gate.py | 161 +++++++++++++++++++++++++++++++ bot_bottle/supervise.py | 2 + tests/unit/test_git_gate.py | 24 +++++ tests/unit/test_supervise.py | 2 + tests/unit/test_supervise_cli.py | 24 +++++ 6 files changed, 249 insertions(+), 7 deletions(-) diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index c0f9096..856a578 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -53,6 +53,7 @@ from ..supervise import ( TOOL_CAPABILITY_BLOCK, TOOL_ALLOW, TOOL_EGRESS_BLOCK, + TOOL_GITLEAKS_ALLOW, archive_proposal, list_pending_proposals, render_diff, @@ -140,6 +141,8 @@ def _suffix_for_tool(tool: str) -> str: return ".dockerfile" if tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK): return ".yaml" + if tool == TOOL_GITLEAKS_ALLOW: + return ".txt" return ".txt" @@ -185,7 +188,7 @@ def approve( qp, action=status, notes=notes, diff_before=diff_before, diff_after=diff_after, ) - if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: + if qp.proposal.tool in (TOOL_CAPABILITY_BLOCK, TOOL_GITLEAKS_ALLOW): archive_proposal(qp.queue_dir, qp.proposal.id) @@ -201,6 +204,23 @@ def reject(qp: QueuedProposal, *, reason: str) -> None: _write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") +def _approve_from_tui( + stdscr: "curses._CursesWindow", # type: ignore + qp: QueuedProposal, + *, + final_file: str | None = None, + notes: str = "", +) -> str: + """Approve from curses, prompting for any tool-specific audit note.""" + if qp.proposal.tool == TOOL_GITLEAKS_ALLOW and final_file is None: + notes = _prompt(stdscr, "allow reason (test fixture/false positive): ") + if not notes: + return "approve aborted (empty reason)" + approve(qp, final_file=final_file, notes=notes) + verb = "modified+approved" if final_file is not None else "approved" + return _approval_status(qp, verb) + + def _write_audit( qp: QueuedProposal, *, @@ -384,18 +404,22 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore _detail_view(stdscr, qp, green_attr=green_attr) elif key == ord("a"): try: - approve(qp) - status_line = _approval_status(qp, "approved") + status_line = _approve_from_tui(stdscr, qp) except ApplyError as e: status_line = f"apply failed: {e}" elif key == ord("m"): + if qp.proposal.tool == TOOL_GITLEAKS_ALLOW: + status_line = "modify unavailable for gitleaks-allow" + continue edited = _modify(stdscr, qp) if edited is None: status_line = "modify aborted (no change)" else: try: - approve(qp, final_file=edited, notes="operator modified before approving") - status_line = _approval_status(qp, "modified+approved") + status_line = _approve_from_tui( + stdscr, qp, final_file=edited, + notes="operator modified before approving", + ) except ApplyError as e: status_line = f"apply failed: {e}" elif key == ord("r"): @@ -493,15 +517,20 @@ def _detail_view( offset = max(0, len(lines) - 1) elif key == ord("a"): try: - approve(qp) + _approve_from_tui(stdscr, qp) except ApplyError: pass return elif key == ord("m"): + if qp.proposal.tool == TOOL_GITLEAKS_ALLOW: + return edited = _modify(stdscr, qp) if edited is not None: try: - approve(qp, final_file=edited, notes="operator modified before approving") + _approve_from_tui( + stdscr, qp, final_file=edited, + notes="operator modified before approving", + ) except ApplyError: pass return diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index c2241f4..239499c 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -247,6 +247,164 @@ cat > "$refs_file" zero=0000000000000000000000000000000000000000 +supervise_gitleaks_allow() { + log_opts=$1 + ref=$2 + report_file=$(mktemp) + if ! gitleaks git \ + --log-opts="$log_opts" \ + --no-banner \ + --redact \ + --ignore-gitleaks-allow \ + --report-format=json \ + --report-path="$report_file" \ + --exit-code 0 \ + 1>&2; then + rm -f "$report_file" + echo "git-gate: gitleaks inline-suppression scan failed for $ref" >&2 + return 1 + fi + + proposal_id=$( + GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY' +import datetime +import hashlib +import json +import os +import sys +import uuid +from pathlib import Path + +report_path = Path(sys.argv[1]) +queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "") +slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "") +if not queue_dir or not slug: + sys.exit(2) + +try: + raw = json.loads(report_path.read_text() or "[]") +except json.JSONDecodeError: + sys.exit(3) +if not isinstance(raw, list): + sys.exit(3) +if not raw: + sys.exit(0) + +ref = os.environ.get("GITLEAKS_ALLOW_REF", "") +lines = [ + "gitleaks inline suppression requires supervisor approval", + f"ref: {ref}", + "", +] +for i, finding in enumerate(raw, 1): + if not isinstance(finding, dict): + continue + file_path = finding.get("File", "") + line_no = finding.get("StartLine", finding.get("Line", "")) + rule_id = finding.get("RuleID", "") + commit = finding.get("Commit", "") + line = finding.get("Line", "") + lines.extend([ + f"finding {i}:", + f" file: {file_path}", + f" line: {line_no}", + f" rule: {rule_id}", + f" commit: {commit}", + f" code: {line}", + "", + ]) + +payload = "\n".join(lines).rstrip() + "\n" +proposal_id = str(uuid.uuid4()) +proposal = { + "id": proposal_id, + "bottle_slug": slug, + "tool": "gitleaks-allow", + "proposed_file": payload, + "justification": ( + "git-gate found gitleaks findings hidden by # gitleaks:allow; " + "approve only for dummy test fixtures or confirmed false positives" + ), + "arrival_timestamp": datetime.datetime.now( + datetime.timezone.utc + ).isoformat(), + "current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(), +} +queue = Path(queue_dir) +queue.mkdir(parents=True, exist_ok=True) +path = queue / f"{proposal_id}.proposal.json" +tmp = path.with_suffix(path.suffix + ".tmp") +with tmp.open("w", encoding="utf-8") as f: + json.dump(proposal, f, indent=2) + f.write("\n") +os.chmod(tmp, 0o600) +os.replace(tmp, path) +print(proposal_id) +PY + ) + rc=$? + rm -f "$report_file" + if [ "$rc" -eq 0 ] && [ -z "$proposal_id" ]; then + return 0 + fi + if [ "$rc" -ne 0 ]; then + echo "git-gate: cannot route # gitleaks:allow finding to supervisor; refusing push" >&2 + return 1 + fi + + queue_dir=${SUPERVISE_QUEUE_DIR:-} + response_file="$queue_dir/${proposal_id}.response.json" + timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300} + case "$timeout" in + ''|*[!0-9]*) + echo "git-gate: invalid SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS=$timeout" >&2 + return 1 + ;; + esac + echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2 + echo "git-gate: approve with './cli.py supervise' to continue this push" >&2 + waited=0 + while [ "$waited" -lt "$timeout" ]; do + if [ -f "$response_file" ]; then + status=$(python3 - "$response_file" <<'PY' +import json +import sys +try: + with open(sys.argv[1], encoding="utf-8") as f: + raw = json.load(f) +except (OSError, json.JSONDecodeError): + sys.exit(1) +status = raw.get("status") +if not isinstance(status, str): + sys.exit(1) +print(status) +PY + ) || status="" + case "$status" in + approved|modified) + mkdir -p "$queue_dir/processed" + mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true + mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true + echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2 + return 0 + ;; + rejected) + echo "git-gate: supervisor rejected # gitleaks:allow for $ref" >&2 + return 1 + ;; + *) + echo "git-gate: invalid supervisor response for # gitleaks:allow" >&2 + return 1 + ;; + esac + fi + sleep 1 + waited=$((waited + 1)) + done + echo "git-gate: supervisor approval timed out for # gitleaks:allow; refusing push" >&2 + return 1 +} + # Phase 1: gitleaks scan each ref's incoming commits. while IFS=' ' read -r old new ref; do [ -z "$ref" ] && continue @@ -268,6 +426,9 @@ while IFS=' ' read -r old new ref; do echo "git-gate: gitleaks rejected push to $ref" >&2 exit 1 fi + if ! supervise_gitleaks_allow "$log_opts" "$ref"; then + exit 1 + fi done < "$refs_file" # Phase 2: forward each ref to the upstream (`origin`, configured diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 8f9b993..9b7aefb 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -51,11 +51,13 @@ SUPERVISE_PORT = 9100 TOOL_CAPABILITY_BLOCK = "capability-block" TOOL_EGRESS_BLOCK = "egress-block" TOOL_ALLOW = "allow" +TOOL_GITLEAKS_ALLOW = "gitleaks-allow" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes" TOOLS: tuple[str, ...] = ( TOOL_ALLOW, TOOL_CAPABILITY_BLOCK, TOOL_EGRESS_BLOCK, + TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, ) diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 939d531..fa6bf3b 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -199,6 +199,30 @@ class TestHookRender(unittest.TestCase): self.assertIn('set -- "$@" --push-option="$opt"', hook) self.assertIn('git push "$@" origin "$refspec"', hook) + def test_inline_gitleaks_allow_routes_to_supervisor(self): + hook = git_gate_render_hook() + # First gitleaks runs normally; only if that passes does the + # hook ask gitleaks to ignore inline allow comments and report + # the suppressed findings for human approval. + self.assertIn("--ignore-gitleaks-allow", hook) + self.assertIn("--report-format=json", hook) + self.assertIn('"tool": "gitleaks-allow"', hook) + self.assertIn("SUPERVISE_QUEUE_DIR", hook) + self.assertIn("SUPERVISE_BOTTLE_SLUG", hook) + self.assertIn("supervisor approved # gitleaks:allow", hook) + self.assertIn("supervisor rejected # gitleaks:allow", hook) + + def test_inline_gitleaks_allow_fails_closed_without_supervisor(self): + hook = git_gate_render_hook() + self.assertIn( + "cannot route # gitleaks:allow finding to supervisor; refusing push", + hook, + ) + self.assertIn( + "supervisor approval timed out for # gitleaks:allow; refusing push", + hook, + ) + class TestAccessHookRender(unittest.TestCase): def test_access_hook_refreshes_origin_on_upload_pack(self): diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 55cb386..87ded1b 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -17,6 +17,7 @@ from bot_bottle.supervise import ( STATUS_MODIFIED, STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, + TOOL_GITLEAKS_ALLOW, archive_proposal, audit_log_path, list_pending_proposals, @@ -320,6 +321,7 @@ class TestToolConstants(unittest.TestCase): supervise.TOOL_ALLOW, TOOL_CAPABILITY_BLOCK, supervise.TOOL_EGRESS_BLOCK, + TOOL_GITLEAKS_ALLOW, supervise.TOOL_LIST_EGRESS_ROUTES, ), supervise.TOOLS, diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index a40df85..9e112ea 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -19,6 +19,7 @@ from bot_bottle.supervise import ( STATUS_MODIFIED, STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, + TOOL_GITLEAKS_ALLOW, read_audit_entries, read_response, sha256_hex, @@ -33,6 +34,7 @@ def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal: TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n", supervise.TOOL_ALLOW: "routes:\n - host: example.com\n", supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n", + TOOL_GITLEAKS_ALLOW: "file: tests/test_fixture.py\nline: 3\n", } payload = payloads.get(tool, "") return Proposal.new( @@ -170,6 +172,28 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): self.assertEqual(STATUS_APPROVED, entries[0].operator_action) self.assertEqual("needed for dev", entries[0].justification) + def test_approve_archives_gitleaks_allow(self): + qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) + supervise_cli.approve(qp, notes="dummy fixture") + resp = read_response(qp.queue_dir / "processed", qp.proposal.id) + self.assertEqual(STATUS_APPROVED, resp.status) + self.assertEqual("dummy fixture", resp.notes) + + def test_tui_gitleaks_allow_requires_reason(self): + qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) + with patch.object(supervise_cli, "_prompt", return_value=""): + status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] + self.assertEqual("approve aborted (empty reason)", status) + self.assertFalse((qp.queue_dir / "processed").exists()) + + def test_tui_gitleaks_allow_writes_reason(self): + qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) + with patch.object(supervise_cli, "_prompt", return_value="test fixture"): + status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] + self.assertIn("approved gitleaks-allow", status) + resp = read_response(qp.queue_dir / "processed", qp.proposal.id) + self.assertEqual("test fixture", resp.notes) + # class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): # # DISABLED — capability_apply functionality is currently commented out. -- 2.52.0 From 83eb9e4041aba301a193214da1e161d6e5b927db Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 10 Jun 2026 07:26:10 +0000 Subject: [PATCH 2/4] docs(prd): add gitleaks allow supervision --- ...gitleaks-inline-suppression-supervision.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/prds/prd-new-gitleaks-inline-suppression-supervision.md diff --git a/docs/prds/prd-new-gitleaks-inline-suppression-supervision.md b/docs/prds/prd-new-gitleaks-inline-suppression-supervision.md new file mode 100644 index 0000000..276828d --- /dev/null +++ b/docs/prds/prd-new-gitleaks-inline-suppression-supervision.md @@ -0,0 +1,101 @@ +# PRD prd-new: Gitleaks inline suppression supervision + +- **Status:** Active +- **Author:** codex +- **Created:** 2026-06-10 +- **Issue:** #208 + +## Summary + +Route every `# gitleaks:allow` inline suppression through the existing +supervisor approval queue before git-gate forwards a push upstream. The +annotation remains available for test fixtures and confirmed false positives, +but it no longer lets an agent silently bypass the gate's secret scan. + +## Problem + +Gitleaks supports inline suppression comments. With the default behaviour, +a pushed line that contains a secret-like value and `# gitleaks:allow` is +ignored by gitleaks and accepted by git-gate. That is convenient for dummy +credentials in tests, but it also gives an agent a one-line bypass for a real +secret. Once the commit lands, the diff alone cannot prove whether the +suppression was legitimate. + +## Goals / Success Criteria + +1. Git-gate continues to run the normal gitleaks scan for every incoming ref. +2. After the normal scan passes, git-gate runs a second scan with + `--ignore-gitleaks-allow` and a JSON report so suppressed findings become + visible. +3. If that second scan reports no suppressed findings, the push proceeds + unchanged. +4. If it reports suppressed findings, git-gate creates a `gitleaks-allow` + supervisor proposal containing the ref, file path, line number, rule, + commit, and flagged line for each finding. +5. The push proceeds only when the supervisor explicitly approves the + proposal; rejection, malformed responses, missing supervisor configuration, + and timeout all refuse the push. +6. The supervisor TUI requires a reason when approving a `gitleaks-allow` + proposal, so the audit trail records whether the approval was for a test + fixture or a false positive. + +## Non-goals + +- Replacing gitleaks or changing the main secret-detection rule set. +- Removing support for `# gitleaks:allow`. +- Automatically classifying fixture files or false positives. +- Adding new supervisor transport or authentication mechanisms. + +## Design + +### Git-gate flow + +`git_gate_render_hook()` emits a `supervise_gitleaks_allow` shell helper. +For each incoming ref, git-gate first runs the existing gitleaks command. If +that scan passes, it runs: + +```sh +gitleaks git \ + --log-opts="$log_opts" \ + --no-banner \ + --redact \ + --ignore-gitleaks-allow \ + --report-format=json \ + --report-path="$report_file" \ + --exit-code 0 +``` + +The second pass keeps the push path non-interactive while producing a report +of findings that would otherwise have been hidden by inline suppression. + +### Supervisor proposal + +When the JSON report contains findings, an embedded Python helper writes a +proposal into `SUPERVISE_QUEUE_DIR` using the existing proposal schema. The +proposal uses: + +- `tool: "gitleaks-allow"` +- a text payload with the ref and each finding's file, line, rule, commit, + and redacted code line +- a justification that tells the operator to approve only dummy test fixtures + or confirmed false positives + +Git-gate then waits for `.response.json` for +`SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS`, defaulting to 300 seconds. +`approved` and `modified` responses allow the push; `rejected`, invalid +responses, invalid timeout configuration, or timeout refuse it. + +### Supervisor UI + +`TOOL_GITLEAKS_ALLOW` is added to the supervisor tool registry. The curses +supervisor renders the proposal as text and allows approval or rejection. +Modification is unavailable for this proposal type because there is no file +patch to apply. Approval from the TUI prompts for a non-empty reason and +writes that reason to the response/audit path. + +### Tests + +Unit tests assert that the rendered git-gate hook includes the second gitleaks +pass, supervisor queue fields, and fail-closed messages. Supervisor tests cover +the new tool constant, proposal archiving, and the required TUI approval +reason. -- 2.52.0 From c666eaa63fb502c3c1913c3ac5b844bf355de934 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 23 Jun 2026 01:43:24 +0000 Subject: [PATCH 3/4] fix: add TOOL_GITLEAKS_ALLOW to __all__ in supervise.py --- bot_bottle/supervise.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 9b7aefb..b27ab5e 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -555,6 +555,7 @@ __all__ = [ "EGRESS_FORWARD_PROXY", "EGRESS_INTROSPECT_URL", "TOOL_CAPABILITY_BLOCK", + "TOOL_GITLEAKS_ALLOW", "TOOL_LIST_EGRESS_ROUTES", "archive_proposal", "audit_dir", -- 2.52.0 From 88c4f61901a5ae8f73355f59f59493fb49440b4d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 23 Jun 2026 02:05:40 +0000 Subject: [PATCH 4/4] fix: don't archive gitleaks-allow response before gate reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI was calling archive_proposal for gitleaks-allow immediately after write_response, moving the response file to processed/ within microseconds. The git-gate shell loop polls queue_dir for the response file every second — it never sees it and hangs until timeout. capability-block is handled by the MCP sidecar which archives after reading; gitleaks-allow is handled by the shell gate which archives after processing. Let the gate own the archive step. --- bot_bottle/cli/supervise.py | 2 +- tests/unit/test_supervise_cli.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index 856a578..5d6bf3a 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -188,7 +188,7 @@ def approve( qp, action=status, notes=notes, diff_before=diff_before, diff_after=diff_after, ) - if qp.proposal.tool in (TOOL_CAPABILITY_BLOCK, TOOL_GITLEAKS_ALLOW): + if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: archive_proposal(qp.queue_dir, qp.proposal.id) diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index 9e112ea..8b9f354 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -172,12 +172,14 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): self.assertEqual(STATUS_APPROVED, entries[0].operator_action) self.assertEqual("needed for dev", entries[0].justification) - def test_approve_archives_gitleaks_allow(self): + def test_approve_gitleaks_allow_leaves_response_for_gate(self): qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) supervise_cli.approve(qp, notes="dummy fixture") - resp = read_response(qp.queue_dir / "processed", qp.proposal.id) + # Gate polls the queue dir for the response; TUI must not archive it. + resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual("dummy fixture", resp.notes) + self.assertFalse((qp.queue_dir / "processed").exists()) def test_tui_gitleaks_allow_requires_reason(self): qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) @@ -191,7 +193,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): with patch.object(supervise_cli, "_prompt", return_value="test fixture"): status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] self.assertIn("approved gitleaks-allow", status) - resp = read_response(qp.queue_dir / "processed", qp.proposal.id) + resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual("test fixture", resp.notes) -- 2.52.0