Skip to content

feat(source-control): bind babysit guard semantics to an executable contract#1285

Open
kyle-sexton wants to merge 6 commits into
mainfrom
feat/1265-guard-contract-suite
Open

feat(source-control): bind babysit guard semantics to an executable contract#1285
kyle-sexton wants to merge 6 commits into
mainfrom
feat/1265-guard-contract-suite

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #1265

Summary

The facts a host permission classifier has to know about the babysit lane — which entry points
mutate, which flags gate which guard, where a refusal is enforced, and how a mutation is actually
performed — were restated in prose by every consumer with nothing detecting drift. This makes them
a table that CI executes, and renders that table to a citable reference doc.

Issue #1265 sketched three options. Option 1 (a capability manifest derived by argparse
introspection) was investigated and falsified: across the nine CLI entry points,
add_mutually_exclusive_group appears in two parsers and neither guards a mutation. Every
inter-flag constraint on a mutating path is imperative post-parse_args() code, and the guard
predicates that matter are boolean expressions over runtime API data — (autonomous or only_outdated) and not thread["isOutdated"] — which argparse cannot see. Mutating-vs-read-only is
not derivable from flag names either: manage_babysit_lease.py's --apply help says "reap only",
yet acquire, heartbeat, and release write lease files unconditionally, so a flag-name-derived
manifest would call acquire read-only. The real axis is not manifest-vs-prose; it is whether each
claim is bound to an executable assertion.

Fix

skills/babysit-prs/scripts/tests/guard_contract.py holds the table as the single source of truth.
test_guards.py executes every row. skills/babysit-prs/reference/guard-contract.md is generated
from the same tables and asserted current, so consumers cite a document CI proves is what the code
does. Every row carries the prose claim it backs — a broken guard fails naming the downstream claim,
not AssertionError: 3 != 2.

Five binding kinds, chosen per fact rather than uniformly:

  • Refusals — the entry point is invoked; exit code, message, and envelope fields asserted. A
    bin/ row additionally asserts which layer refused, using the observable discriminator that a
    bash-wrapper refusal never reaches the interpreter and so emits no JSON envelope. This is what
    pins the bash-vs-Python asymmetry: source-control-babysit-merge filters --allow-unpinned-head
    in bash, while source-control-babysit-resolve-thread is a pure passthrough whose
    --allow-unpinned-thread refusal demonstrably comes from Python.
  • Predicates — the classifier is called directly, because --autonomous's isOutdated
    requirement and the bot-only line are conditions over fetched API data that no argument shape
    expresses. Includes the claim consumers get wrong: "resolves only outdated bot threads" is true
    only under --autonomous or --only-outdated.
  • Effects — run offline against a throwaway state dir, comparing state before and after. This
    is what proves manage_babysit_lease.py acquire writes with no --apply.
  • Mechanisms — asserted against source, because refresh_pr_branch.py calls GitHub's
    server-side update-branch and never pushes; a consumer reasoning about push guards is reasoning
    about the wrong mechanism.
  • Documented command lines — every bin/-path wrapper command spelled in a plugin document is
    checked against the backing CLI's own parser. This covers reference/safety.md
    §Pinned-Command Degradation, the live instance of this issue inside the plugin's own docs.

Catalogue gates fail when a new entry point, wrapper, or command-spelling document arrives without a
row, and when an entry point's mutation classification has nothing asserting it.

Deferred with its trigger recorded at guard_contract.py's docstring: an argparse flag catalogue
(names, types, defaults) would additionally catch a renamed flag or changed default that no behavior
row exercises, but needs a build_parser() extraction across all nine entry points, whose parsers
are built inside main().

The existing hand-written cases in test_guards.py were ported to rows with their assertions
intact and strengthened — the scope rows now assert inScope is present and false rather than
payload.get("inScope") being falsy, which passed vacuously when the key was absent. The bash-level
wrapper checks in engine.test.sh are left in place untouched.

Verification

bash plugins/source-control/skills/babysit-prs/scripts/engine.test.sh:

== unittest suite ==
Ran 348 tests in 63.549s

OK
== ruff ==
All checks passed!
== guarded-wrapper behavior ==
PASS: merge wrapper rejects --allow-unpinned-head
PASS: merge wrapper reaches fail-closed CLI (no allowlist)
PASS: resolve wrapper reaches fail-closed CLI (no allowlist)
PASS: merge wrapper rejects --autopilot-merge-tier without required sets

Fault injection — relaxing classify's isOutdated condition to only_outdated and not thread["isOutdated"] produced:

FAIL: test_every_predicate_row (row='classify.autonomous-requires-outdated')
AssertionError: 'eligible' != 'skipped-not-outdated'
GUARD CONTRACT ROW `classify.autonomous-requires-outdated` NO LONGER HOLDS.
  claim: --autonomous requires the deterministic isOutdated 'addressed' signal: a still-current bot
  thread is skipped so an unattended worker cannot resolve a live finding and self-satisfy the merge
  gate.
  Fix the guard, or update the row in scripts/tests/guard_contract.py and regenerate
  reference/guard-contract.md.
  observed: classify returned 'eligible'

Two real defects were caught by the gates while building them: the doc-coverage gate found that
reference/orchestration.md carries its own copy of the wrapper command lines (now a covered row),
and markdownlint caught a dropped ## [0.26.0] heading in the CHANGELOG.

Also run clean: markdownlint-cli2 over the four changed markdown files,
scripts/check-changelog-parity.sh --check-bump origin/main, scripts/check-silent-skips.sh.
scripts/check-orphaned-fixtures.sh exceeded a local timeout on this machine and is left to CI;
this PR adds no eval fixtures.

Related

…ontract

The facts a host permission classifier must know about the babysit lane --
which entry points mutate, which flags gate which guard, where a refusal is
enforced, and how a mutation is actually performed -- were restated in prose by
every consumer with nothing detecting drift.

They are now a table in scripts/tests/guard_contract.py, executed row by row
against the real entry points by test_guards.py, and rendered to a citable
reference/guard-contract.md that CI proves current. Every row carries the prose
claim it backs, so a changed guard fails naming the downstream claim rather
than reporting an exit-code mismatch.

Five binding kinds, chosen per fact rather than uniformly:

- refusals, invoked for real; a bin/ row additionally asserts WHICH layer
  refused, via the observable discriminator that a bash-wrapper refusal emits
  no JSON envelope
- predicates, calling the classifier directly, because --autonomous's
  isOutdated requirement is a condition over fetched API data that no argument
  shape can express
- effects, run offline against a throwaway state dir; this is what proves
  manage_babysit_lease.py acquire writes with no --apply, contrary to what its
  flag names suggest
- mechanisms, asserted against source, because refresh_pr_branch.py never
  pushes and a consumer reasoning about push guards reasons about the wrong
  thing
- documented command lines, checked against the backing CLI's own parser

Catalogue gates fail when a new entry point, wrapper, or command-spelling
document arrives without a row. The argparse flag catalogue stays deferred with
its trigger recorded at the module docstring.

Closes #1265
Three tightenings on the guard contract:

- Scope refusals now assert `inScope` is present and false, not merely falsy.
  A consumer branches on that field, and `payload.get("inScope")` passed
  vacuously when the key was absent.
- The unclassified-document gate sweeps the whole plugin rather than the
  babysit skill alone: a wrapper command line copied into a command, an agent,
  or a sibling skill drifts the same way.
- The deferred flag catalogue is recorded in the sanctioned TODO(#issue) form
  the comment-hygiene policy allows.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 23s —— View job


PR Review: feat(source-control): bind babysit guard semantics to an executable contract

Tasks

  • Gather context and understand the PR
  • Read changed files and PR diff
  • Review guard_contract.py (core new file)
  • Review test_guards.py changes
  • Review reference doc and markdown changes
  • Review plugin.json / CHANGELOG
  • Synthesize and post findings

Overview

This is a well-conceived addition that converts a prose-only contract into an executable one. The five binding kinds (Refusals, Predicates, Effects, Mechanisms, DocCommandSources) are appropriately chosen per fact rather than applied uniformly — that's the right design decision. The GeneratedDocIsCurrent gate, the test_anchor_symbol_exists check, the test_every_doc_naming_a_wrapper_is_covered sweep, and the PATH-clearing test that proves the refusal suite genuinely needs no network access are all strong. The vacuous-pass fix for envelope fields (inScope present AND false, not merely falsy) is the right strengthening.

A few things to consider:


Findings

1. heartbeat and release are claimed mutating but have no effect rows

guard_contract.py LEASE_CLI entry point:

gate=(
    "none for acquire / heartbeat / release -- they write unconditionally; "
    "--apply gates reap's deletion ONLY"
),

The EFFECTS table (L649–L695) exercises acquire, reap (dry-run), and reap --apply. The claim that heartbeat and release write unconditionally is stated in the entry point's gate field but backed only by acquire's effect row. A consumer reading the contract and citing "unconditional writes on heartbeat/release" has a claim that no row executes.

The test_every_mutating_entry_point_is_backed gate doesn't catch this because backed_by is non-empty (acquire row satisfies it). Worth adding effect rows for heartbeat and release in a follow-up, or documenting the conscious deferral in the # Not covered here section like the flag-catalogue deferral.


2. seed() parses proc.stdout without stripping — fragile if output format changes

test_guards.py L93–94:

lease_path = pathlib.Path(str(json.loads(proc.stdout)["path"]))

If manage_babysit_lease.py acquire ever adds a log line before or after the JSON object, this parse fails with json.JSONDecodeError rather than the AssertionError that would name the fixture problem. Minimal fix: json.loads(proc.stdout.strip()), or a try/except that re-raises as AssertionError(f"lease fixture: unexpected acquire output: {proc.stdout!r}"). Currently correct but brittle at the interface between the fixture helper and the script's output contract.


3. resolve.allowlist-absent error message not asserted; merge counterpart is

guard_contract.py L325–337:

Refusal(
    id="resolve.allowlist-absent",
    ...
    error_contains=(),          # ← empty
    envelope_fields=(("inScope", False),),
),

merge.allowlist-absent has error_contains=("allowed-owners",) (L173). The envelope field partially compensates, but if the refusal mechanism moved to a layer that doesn't produce a JSON envelope, the row would pass on exit code alone. The asymmetry is defensible (the envelope is the observable discriminator), but naming a token like "allowed-owners" as the error string would be a belt-and-suspenders addition consistent with the merge row.


4. refused_by is not validated for non-bin/ rows

test_guards.py L143–161:

if row.entry_point.startswith("bin/"):
    if row.refused_by == contract.BASH_WRAPPER:
        ...
    else:
        ...

For non-bin/ rows, refused_by is never checked. A typo like refused_by="pyhton-cli" silently documents the wrong thing. Adding a self.assertIn(row.refused_by, (contract.BASH_WRAPPER, contract.PYTHON_CLI), ...) check in test_every_refusal_row before the bin/ branch would make invalid values fail rather than be silently ignored.


5. __main__ detection heuristic could match non-entry-point files

test_guards.py L248–250:

present = {
    f"skills/babysit-prs/scripts/{path.name}"
    for path in contract.SCRIPTS.glob("*.py")
    if "__main__" in path.read_text(encoding="utf-8")
}

"__main__" can appear in docstrings, comments, or as a string literal in a file that isn't itself an entry point. 'if __name__ == "__main__"' in text is a tighter check. In the current scripts directory this is unlikely to matter, but it's the kind of thing that bites when a utility module adds an __main__ reference.


Minor observations (no action required)

  • documented_commands() regex capture: The ([^]*)capture group stops at the next backtick, which over-captures in fenced blocks. The function's blank-line truncation and conservative flag-extraction approach (any--flag` in the tail is validated) means over-capture only strengthens validation. The comment acknowledges this is an explicit choice over a catastrophically backtracking alternative.

  • FINDINGS_CLI read-only classification: No backed_by rows needed for a read-only entry point, but there's nothing preventing a misclassification of a mutating script as read-only — a future script that writes and is marked read-only would pass test_every_mutating_entry_point_is_backed without any mechanism row. This is an inherent asymmetry of the design.

  • invoke() CWD: Using cwd=tempfile.gettempdir() to prove wrapper self-location is clean and the comment makes the intent clear. No issue, just worth calling out as intentional.


Summary

Items 1–4 are the actionable ones. Items 1 and 4 can be addressed as follow-up (item 1 is explicitly deferred territory; item 4 is a two-line assertion), while item 2 (parse fragility in seed()) and item 3 (asymmetric error_contains on resolve.allowlist-absent) could be addressed inline before merge. None of these are blockers for the core contract structure, which is sound.

CHANGELOG entry, version bump (0.26.0 → 0.27.0), guard-contract.md content, and the safety.md cross-reference are all correct.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 27s —— View job


Security Review — PR #1285

Tasks

  • Gather context and understand request
  • Read changed files from PR diff
  • Review guard_contract.py for security issues
  • Review test_guards.py for security issues
  • Review reference docs and other changed files
  • Post security findings

Scope

All 7 changed files reviewed:

  • plugins/source-control/.claude-plugin/plugin.json — version bump only
  • plugins/source-control/CHANGELOG.md — documentation only
  • plugins/source-control/skills/babysit-prs/SKILL.md — documentation only
  • plugins/source-control/skills/babysit-prs/reference/guard-contract.md — generated reference doc
  • plugins/source-control/skills/babysit-prs/reference/safety.md — documentation addition
  • plugins/source-control/skills/babysit-prs/scripts/tests/guard_contract.py — new: contract tables + markdown emitter
  • plugins/source-control/skills/babysit-prs/scripts/tests/test_guards.py — modified: contract runner

Findings

No CRITICAL or IMPORTANT security issues found.

This PR is entirely test infrastructure. All subprocess calls use the list form (no shell=True, no command injection surface). All file paths are derived from pathlib.Path operations on hardcoded constants. No secrets, credentials, or tokens are handled. No GitHub Actions workflows are modified.


SUGGESTION — confidence: medium

test_guards.py lines 120, 208: argv strings in the contract tables are expanded with arg.format(state_dir=tmp), which invokes Python's full format mini-language rather than simple substitution. A future contributor adding an argv row with an unintended {placeholder} gets a silent wrong value (if the name happens to match an attribute of the state_dir string) or an opaque KeyError at test runtime rather than a clear "unexpected format token" error. There is no current exploitable row — all existing argv strings that use {state_dir} are correct — but the pattern is fragile at scale. arg.replace("{state_dir}", tmp) would be strictly safer and equally expressive for this use case.


SUGGESTION — confidence: low

test_guards.py lines 93–97: seed() constructs lease_path from the "path" field in manage_babysit_lease.py's stdout without verifying that the path is inside the test's temp directory. If manage_babysit_lease.py ever has a path-traversal bug in its output, seed() would resolve and overwrite an arbitrary file on the CI host. At present the LEASE_CLI is invoked with --state-dir pointing at the tempdir, and the output path should be inside it, so this is a latent concern rather than a current vulnerability. Adding a lease_path.relative_to(state_dir) assertion after line 93 would make it explicit.


Positive observations

  • The WRAPPER_COMMAND regex ([^]*)` avoids the catastrophic backtracking the code comment describes; the negated-character-class quantifier is linear in input length.
  • invoke() uses cwd=tempfile.gettempdir() to prevent stray repo-relative config from silently satisfying a guard — good defensive design.
  • The test_scope_refusal_precedes_every_network_call test empties PATH to prove no subprocess reaches gh; this is the right way to assert network-free behavior rather than mocking.
  • The envelope-field assertion strengthening (inScope must be present and false, not merely falsy) closes a vacuous-pass class of test error.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 455022b13d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

path=MERGE_CLI,
wrapper=MERGE_WRAPPER,
mutation=CONDITIONAL,
mutates_what="merges the PR on GitHub; writes queue state",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the nonexistent merge state write

The mutation catalogue says babysit_merge.py writes queue state, but that entry point has no state-directory argument or state-writing path; after gh pr merge it only prints the result and returns (babysit_merge.py:1077-1092). Consumers of this new contract will therefore infer a local mutation that never occurs, and the tests do not catch the false claim because test_every_mutating_entry_point_is_backed merely checks that backed_by is nonempty.

Useful? React with 👍 / 👎.

Comment on lines +883 to +885
gate="--write-state (absent: reports the snapshot without persisting it)",
claim=(
"No GitHub write on any path. The only mutation is the local snapshot file."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for quarantine writes without --write-state

When the configured state file contains malformed JSON, a flagless snapshot is not read-only: build_snapshot always calls babysit_state.load_state, whose corruption path renames the file to a .corrupt sibling (babysit_state.py:78-102). Thus --write-state does not gate every mutation, and a permission classifier relying on this row can authorize an invocation under the false assumption that it cannot alter local state.

Useful? React with 👍 / 👎.

SKILL.md is at its 500-line hard cap; a second pointer would have left the
file exactly at the cap for the next editor. SKILL.md already routes the
guarded-mutation section to reference/safety.md, which now points at the
contract, so one canonical pointer carries it.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 50s —— View job


PR Review: feat(source-control): bind babysit guard semantics to an executable contract

Tasks

  • Gather context and understand the PR
  • Read changed files and diff
  • Review latest commits (455022b, 5515b19) against prior review findings
  • Assess Codex findings on babysit_merge.py mutation claim and quarantine writes
  • Synthesize and post review

What the latest commits addressed

455022b added three improvements:

  • envelope_fields strengthening — scope refusal rows now assert inScope is present and false, not just falsy. This was the vacuous-pass issue raised in the prior review (Finding 3). ✓ Addressed.
  • Doc sweep widenedtest_every_doc_naming_a_wrapper_is_covered now scans the whole plugin tree rather than just the babysit skill, so a wrapper command line copied into any sibling skill or agent is caught. ✓ Strong improvement.
  • TODO(#1265) format — deferred flag-catalogue note now uses the repo's comment-hygiene convention. ✓ Addressed.

5515b19 removed the guard-contract.md back-link from SKILL.md to stay under the file's 500-line hard cap, routing through safety.md instead. The pointer chain is still reachable and unambiguous.


Confirmed Codex findings

1. babysit_merge.py's mutates_what is factually wrong

guard_contract.py L784:

mutates_what="merges the PR on GitHub; writes queue state",

babysit_merge.py does not import babysit_state, has no save_state call, accepts no --state-dir argument, and its main() function terminates by printing JSON and returning. The "writes queue state" half is false. A consumer reading this row applies a broader permission rule than the script requires — exactly the kind of drift this contract exists to prevent, and exactly what the contract cannot catch because the mutation is flagged CONDITIONAL, so test_every_mutating_entry_point_is_backed passes (the backed_by refs are real).

Fix: change mutates_what to "merges the PR on GitHub" and drop ; writes queue state.

2. snapshot.write-state-gates-the-only-write claim overstates read-only scope

guard_contract.py L739–745:

claim=(
    "pr_queue_snapshot.py's single write is the local snapshot, and --write-state "
    "is the only thing that reaches it. Nothing on any path writes to GitHub, so "
    "a flagless invocation is a pure read of the queue."
),
entry_point=SNAPSHOT_CLI,
must_contain=("if args.write_state:", "save_state("),

pr_queue_snapshot.py unconditionally calls state_store.load_state(state_path) (before the --write-state branch). When the state file contains corrupt JSON, load_state calls quarantine_corrupt_state, which renames the file to a .{stamp}.corrupt sibling — a filesystem mutation that happens with no flag. The mechanism test's must_contain check on source text cannot catch this path. The claim "a flagless invocation is a pure read of the queue" is therefore false under a corrupt-state scenario.

The practical impact for a permission classifier is limited (the quarantine is a rename, not a GitHub write, and only occurs on corrupt state), but the contract explicitly promises to be what CI proves — and CI does not prove this.

Fix options: (a) narrow the claim to "no GitHub write on any path; the only intentional local write is the local snapshot file, gated by --write-state; corrupt-state quarantine may rename an existing file as a side-effect of load_state", or (b) add a must_not_contain=("quarantine_corrupt_state",) note with a comment explaining the tradeoff, making the limitation explicit.


Still-open findings from prior reviews

These were not addressed in the latest two commits — noting them here for completeness so they are not lost:

Finding Location Status
heartbeat / release write unconditionally but have no effect rows guard_contract.py LEASE_CLI entry Open — deferred territory; worth a # Not covered here note
seed() parses proc.stdout without .strip() — fragile if output grows a trailing newline test_guards.py L93 Open
resolve.allowlist-absent has empty error_contains while merge.allowlist-absent names "allowed-owners" — asymmetric coverage guard_contract.py L333 Partially addressed (envelope field added); asymmetry remains
refused_by for non-bin/ rows is not validated — a typo like "pyhton-cli" silently documents the wrong refuser test_guards.py L143 Open
"__main__" in text detection catches docstring/comment occurrences; 'if __name__ == "__main__"' in text would be tighter test_guards.py L250 Open
arg.format(state_dir=tmp) uses Python's full format mini-language; arg.replace("{state_dir}", tmp) would be safer test_guards.py L120 Open
seed() builds lease_path from untrusted subprocess stdout without asserting it is inside the temp dir test_guards.py L93 Open

The two Codex items above are the ones most worth resolving before merge: Item 1 is a factual error in the published contract, and Item 2 is an inaccuracy in a claim the contract's own prose says CI proves.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 0s —— View job


Security Review — PR #1285

Tasks

  • Gather context and understand request
  • Read PR diff and changed files
  • Review guard_contract.py for security issues
  • Review test_guards.py for security issues
  • Review reference docs and other changed files
  • Post security findings

Scope

All 6 changed files reviewed:

  • plugins/source-control/.claude-plugin/plugin.json — version bump only
  • plugins/source-control/CHANGELOG.md — documentation only
  • plugins/source-control/skills/babysit-prs/reference/guard-contract.md — generated reference doc
  • plugins/source-control/skills/babysit-prs/reference/safety.md — documentation addition
  • plugins/source-control/skills/babysit-prs/scripts/tests/guard_contract.py — new: contract tables + markdown emitter
  • plugins/source-control/skills/babysit-prs/scripts/tests/test_guards.py — modified: contract runner

Findings

No CRITICAL or IMPORTANT security issues found.


SUGGESTION — snapshot.write-state-gates-the-only-write mechanism row contains a false claim

Confidence: high (confirmed against babysit_state.py and pr_queue_snapshot.py, which are not in the diff but are referenced by the new contract)

guard_contract.py L737–747:

Mechanism(
    id="snapshot.write-state-gates-the-only-write",
    claim=(
        "pr_queue_snapshot.py's single write is the local snapshot, and --write-state "
        "is the only thing that reaches it. Nothing on any path writes to GitHub, so "
        "a flagless invocation is a pure read of the queue."
    ),
    entry_point=SNAPSHOT_CLI,
    must_contain=("if args.write_state:", "save_state("),
),

The must_contain assertion verifies that save_state is gated by if args.write_state: — which is correct. But the claim says --write-state gates the only write, and that a flagless invocation is a pure read. This is false.

pr_queue_snapshot.py calls state_store.load_state(state_path) unconditionally at line 172 (before any flag check). babysit_state.py::load_state() lines 78–102 include:

except ValueError:
    quarantine_path = quarantine_corrupt_state(path)   # renames the file
    ...
    return _cold_state()

quarantine_corrupt_state() calls path.replace(quarantine_path) — a filesystem rename from the state file to a .corrupt sibling. This write is not gated by --write-state. A flagless pr_queue_snapshot.py invocation against a malformed state file renames that file, contradicting the "pure read" claim.

Why this matters for security: The guard contract is explicitly designed as a citable reference for host permission classifiers (see safety.md §Harness Permission Layer). A classifier reading this row and allowing flagless snapshot invocations under a read-only permission set would be wrong: the script can rename the state file without --write-state. The severity is bounded (local filesystem rename only, no GitHub write, requires a pre-existing corrupt state file), but the contract document would be inaccurate for exactly the use case it exists to serve.

The test gap: The mechanism test would need must_not_contain=("quarantine_corrupt_state",) or a must_not_contain on the underlying load_state import path to catch this — neither is present, so the row passes CI while the claim is false.

Fix this →


Confirmed from prior security review (not re-raised, no action needed)

The prior automated security review at 2026-07-25T02:32:30Z already identified:

  • arg.format(state_dir=tmp) in test_guards.py L120 using Python's full format mini-language rather than a simple .replace(). All current argv strings are hardcoded in guard_contract.py, so no current exploitable row exists — the concern is about fragility under future edits, not a present vulnerability.
  • seed() reading lease_path from subprocess stdout without verifying it is inside the temp directory (lease_path.relative_to(state_dir) assertion missing). Latent concern only; requires a path-traversal bug in manage_babysit_lease.py to matter.

Both remain valid suggestions, neither is a current exploitable issue.


Positive observations

  • All subprocess calls use the list form throughout test_guards.py. No shell=True anywhere.
  • WRAPPER_COMMAND regex limits the wrapper name capture to [a-z-]+, preventing path traversal in wrapper names extracted from markdown documents.
  • test_scope_refusal_precedes_every_network_call empties PATH to prove no subprocess reaches gh — the right way to assert network-free behavior rather than mocking.
  • plugin_path() resolves from __file__ with no user-supplied path components.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5515b19d37

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

wrapper=None,
mutation=CONDITIONAL,
mutates_what="records feedback dispositions and fix rounds in queue state",
gate="--apply, additionally requiring --lease-token",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include lock-file creation in the mutation gate

Unlike the already-reported corrupt-snapshot path, even a valid flagless ledger invocation mutates the filesystem: manage_feedback_ledger.run() enters state_lock(queue-state.json) before checking args.apply, and that lock creates the state directory and queue-state.json.lock when absent. Therefore --apply is not the condition under which mutation happens at all, so a permission classifier relying on this row can incorrectly treat a dry-run invocation as non-writing; either account for lock artifacts in the gate or explicitly narrow the contract to domain-state mutations.

Useful? React with 👍 / 👎.

Comment on lines +307 to +309
WRAPPER_COMMAND = re.compile(
r'bash "\$\{CLAUDE_PLUGIN_ROOT\}/(bin/source-control-babysit-[a-z-]+)"([^`]*)'
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate each wrapper command in multi-command blocks

When a fenced block contains multiple commands, this pattern's ([^]*)consumes every line after the first wrapper through the closing backtick, sofindall()returns only the first command. This already happens inreference/safety.md`, where the adjacent resolve-wrapper command is swallowed into the merge command's tail; consequently its wrapper path is never checked and any flags are validated against the merge CLI rather than the resolve CLI. Parse each wrapper invocation independently so a typo or stale flag on a later command cannot pass this contract.

Useful? React with 👍 / 👎.

Four review findings, each a claim the contract published that CI did
not actually prove.

- babysit_merge.py's entry row claimed it "writes queue state". It
  imports no state module and calls write_state zero times. The clause
  stays on refresh_pr_branch.py and request_review.py, which call it
  three times each.
- The entry-point table declared `gate` the condition under which the
  mutation happens "at all", but every state-touching script creates its
  state directory and a `.lock` sibling on entering state_lock, before
  any --apply check. The table now scopes `mutation` to domain state and
  names the housekeeping it deliberately excludes, so a classifier
  reading it still grants the script writes inside --state-dir rather
  than treating a dry run as writing nothing.
- The snapshot mechanism claimed a flagless invocation is "a pure read".
  load_state runs unconditionally, so an already-corrupt state file is
  quarantined by rename whatever the flags say.
- The documented-command parser's tail ran to the closing backtick, so a
  fenced block yielded only its first command. reference/safety.md was
  already hitting this: the resolve-wrapper invocation was swallowed
  into the merge command's tail and its flags validated against the
  merge CLI. Each invocation is now bounded by the next one, surfacing
  six commands there where five were seen.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 47s —— View job


Security Review — PR #1285

Tasks

  • Gather context and understand request
  • Read PR diff and changed files
  • Review guard_contract.py for security issues
  • Review test_guards.py for security issues
  • Review reference docs and other changed files
  • Post security findings

Scope

All 6 changed files reviewed across all 4 commits (d1745d5455022b5515b194bf9eb9):

  • plugins/source-control/.claude-plugin/plugin.json — version bump only
  • plugins/source-control/CHANGELOG.md — documentation only
  • plugins/source-control/skills/babysit-prs/reference/guard-contract.md — generated reference doc
  • plugins/source-control/skills/babysit-prs/reference/safety.md — documentation addition
  • plugins/source-control/skills/babysit-prs/scripts/tests/guard_contract.py — new: contract tables + markdown emitter
  • plugins/source-control/skills/babysit-prs/scripts/tests/test_guards.py — modified: contract runner

No CRITICAL or IMPORTANT security issues found.

This is test infrastructure. All subprocess calls use the list form throughout — no shell=True, no command injection surface. No secrets, credentials, or tokens are handled. No GitHub Actions workflows are modified. No external dependencies introduced.

The two IMPORTANT factual errors in the contract document (babysit_merge.py falsely claimed to write queue state; snapshot.write-state-gates-the-only-write claimed a flagless invocation was a pure read) were fixed in commit 4bf9eb9. The entry-point table preamble now explicitly scopes mutation to domain state and calls out that housekeeping writes (lock files, load_state quarantine renames) happen regardless of flags.


SUGGESTION — arg.format(state_dir=tmp) uses Python's full format mini-language

Severity: SUGGESTION Confidence: medium

test_guards.py line 120 and line 208:

argv = tuple(arg.format(state_dir=tmp) for arg in row.argv)

arg.format(state_dir=tmp) invokes Python's full format mini-language rather than a literal string replacement. All current argv tuples in guard_contract.py are hardcoded literals, so no row is currently exploitable. However, a future contributor adding an argv row with an unintended {placeholder} gets a KeyError at test runtime (or, if the placeholder accidentally matches an attribute of a str object, a silently wrong value substituted into the subprocess argv). arg.replace("{state_dir}", tmp) is strictly safer and equally expressive for this use case.

Fix this →


SUGGESTION — seed() reads lease_path from subprocess stdout without bounding it to the temp dir

Severity: SUGGESTION Confidence: low

test_guards.py lines 93–97:

lease_path = pathlib.Path(str(json.loads(proc.stdout)["path"]))
record = json.loads(lease_path.read_text(encoding="utf-8"))
...
lease_path.write_text(json.dumps(record), encoding="utf-8")

lease_path is taken directly from manage_babysit_lease.py's stdout, without asserting it lies inside the test's temporary directory. If that script ever had a path-traversal bug in its output, seed() would follow the path and overwrite an arbitrary file on the CI host. At present the --state-dir argument points at the tempdir, so the output path should be inside it — this is a latent concern, not a current vulnerability. Adding lease_path.relative_to(state_dir) (which raises ValueError if the path escapes) after line 93 would make the invariant explicit.

Fix this →


Positive observations

  • All subprocess calls use the list form throughout test_guards.py. No shell=True anywhere in the changed files.
  • WRAPPER_COMMAND regex limits the wrapper name capture to [a-z-]+, preventing path traversal in wrapper names extracted from markdown documents.
  • test_scope_refusal_precedes_every_network_call empties PATH to prove no subprocess reaches gh — the right way to assert network-free behavior rather than mocking.
  • The documented_commands() refactor in commit 4bf9eb9 splits the regex capture from tail extraction, correctly stopping the tail at the next wrapper match — this fixes the multi-command-block issue without introducing a new injection surface.
  • plugin_path() resolves entirely from __file__ with no user-supplied path components.
  • The entry-point preamble now explicitly tells classifiers that gate-less invocations still need write access inside --state-dir for housekeeping, preventing a false "read-only" permission grant.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 26s —— View job


PR Review: feat(source-control): bind babysit guard semantics to an executable contract

Tasks

  • Gather context and understand the PR
  • Check latest commit against prior findings
  • Review changed files at HEAD
  • Synthesize and post findings

Latest commit (4bf9eb9) — all four prior findings addressed

The commit message names the four fixes exactly; each one checks out:

1. babysit_merge.py "writes queue state" — fixed

guard_contract.py L784:

- mutates_what="merges the PR on GitHub; writes queue state",
+ mutates_what="merges the PR on GitHub",

Correct. babysit_merge.py has no state import and calls write_state zero times. The false claim is gone.

2. Lock-file / domain mutation scope — fixed

The entry-point table header now explicitly scopes mutation to domain state and names the housekeeping it deliberately excludes: state_lock creates the state directory and .lock sibling; load_state may quarantine a corrupt file. The paragraph is present in both guard_contract.py's renderer and guard-contract.md.

guard_contract.py L968–981: the note is part of the generated output, so it stays in sync.

3. Snapshot "pure read" claim — fixed

guard_contract.py L737–748:

- "a flagless invocation is a pure read of the queue."
+ "a flagless invocation reads the queue and persists nothing of it.
   Housekeeping is out of scope per the entry-point table's mutation
   definition: this script loads state unconditionally, so load_state may
   quarantine an already-corrupt state file whatever the flags say."

The claim is now accurate. The snapshot entry-point row (guard_contract.py L882–887) similarly updated to "The only domain-state mutation is the local snapshot file." A permission classifier reading this contract will no longer grant read-only permissions expecting no filesystem writes.

4. Multi-command block parser — fixed

test_guards.py L308–327:

- r'bash "\$\{CLAUDE_PLUGIN_ROOT\}/(bin/source-control-babysit-[a-z-]+)"([^`]*)'
+ r'bash "\$\{CLAUDE_PLUGIN_ROOT\}/(bin/source-control-babysit-[a-z-]+)"'

The new documented_commands() iterates finditer matches and bounds each command's tail by the next wrapper's start position, so a fenced block with both merge and resolve invocations yields two independently validated entries. The resolve-thread flags are no longer validated against the merge CLI. The comment correctly explains why the old approach failed and why the fix is correct.


Still-open minor items (none are blockers)

These carry over from prior review rounds; none were addressed in this commit, none prevent merge.

Finding Location Status
heartbeat/release write unconditionally but have no effect rows; not mentioned in "Not covered here" guard_contract.py L824–825 Open — low priority
seed() parses proc.stdout without .strip() — fragile if output grows trailing whitespace test_guards.py L93 Open
resolve.allowlist-absent has empty error_contains; merge.allowlist-absent names "allowed-owners" — asymmetric coverage guard_contract.py L333 Open
refused_by for non-bin/ rows is never validated — a typo silently documents the wrong refuser test_guards.py L143 Open
"__main__" in text catches docstrings/comments; 'if __name__ == "__main__"' in text would be tighter test_guards.py L250 Open
arg.format(state_dir=tmp) uses full format mini-language; arg.replace("{state_dir}", tmp) would be safer test_guards.py L120 Open
seed() builds lease_path from subprocess stdout without asserting it is inside the temp dir test_guards.py L93 Open

The heartbeat/release gap is the most worth a one-liner mention in "Not covered here" before merge — a consumer looking for heartbeat write coverage in the contract should find a note saying it was consciously deferred rather than hitting a dead end.


Summary

The latest commit cleanly addresses all four actionable findings from the Codex and prior Claude reviews: the false state-write claim on babysit_merge.py, the lock-file/domain mutation scope ambiguity, the snapshot "pure read" inaccuracy, and the multi-command parser that silently skipped the resolve-thread wrapper. The contract is now factually accurate on the claims that were wrong. The remaining open items above are robustness and cosmetic improvements that can be addressed as follow-on work.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4bf9eb97ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +299 to +301
"The bin/ wrapper refuses --allow-unpinned-head in bash, before Python runs: "
"no allow-rule-covered invocation of the bare command can merge an unvetted "
"head. The refusal prints plain text to stderr and emits no JSON envelope, "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject abbreviated unpinned-head flags

When the wrapper receives an unambiguous prefix such as --allow-unpinned-hea, its Bash loop does not match the exact forbidden spelling, while Python's argparse accepts the prefix as --allow-unpinned-head and sets the override. Against this commit, the full flag exits from the wrapper but the abbreviated form reaches the Python scope check, so an allow-rule-covered invocation can merge a ready PR without --expected-head, contradicting this contract. Disable argparse abbreviation or reject abbreviated forms in the wrapper and cover that invocation here.

Useful? React with 👍 / 👎.

kyle-sexton added a commit that referenced this pull request Jul 25, 2026
…oop.md §5.3 (#1307)

*This was generated by AI during an autonomous work-loop execution
session.*

Closes #653

## Summary

`babysit-prs`'s SKILL.md described a cadence-ownership split that no
longer exists.

#652 added the engine-backed `recommended_cadence` →
`ScheduleWakeup.delaySeconds` mapping table to
`reference/loop.md` §5.3, alongside the static Python-free degrade
ladder already living there.
`reference/cadence.md` owns only the cadence *states and thresholds* —
and has said so since #322
("`loop.md` owns the wake mechanics, this file owns the states and
thresholds behind the
recommendation"). SKILL.md still routed readers the old way:

| Surface | Before | After |
|---|---|---|
| Runbook step 9 | `recommended_cadence` per `cadence.md`; `loop.md`
§5.3 named only as the "static ladder … Python-free degrade" | `Schedule
the next wake per the cadence contract in reference/loop.md §5.3.` |
| Reporting closing line | "Recommend the exact next interval per
`cadence.md`" | same sentence, now citing `loop.md` §5.3 |
| References entry for `loop.md` | "…checklist, static cadence ladder" |
"…checklist, and the §5.3 cadence contract" |
| Step-5 progressive-disclosure trigger | load `cadence.md` "only before
**recommending cadence**" | load `cadence.md` "only before
**interpreting a cadence state**" |

The filed issue named step 9. The other three are the same defect:
leaving them would have made the
file internally inconsistent about which section owns the seconds.

**The Reporting line was a live wrong-number risk, not just
imprecision.** `cadence.md` states
`idle` = **daily**; §5.3 documents `ScheduleWakeup` clamping
`delaySeconds` to `[60, 3600]`, so
inside `/loop` `idle` and `quiet` both wake hourly. "Recommend the exact
next interval per
`cadence.md`" could therefore surface an interval the loop will never
schedule.

The `cadence.md` link is dropped from step 9 rather than kept alongside
`loop.md`: §5.3 already
hops to `cadence.md` for the states in one line, so a second pointer
here is the gratuitous
cross-reference `CLAUDE.md`'s pointer-not-copy rule forbids.
`cadence.md` keeps its own References
entry and its step-5 disclosure trigger. The new step-9 wording matches
vocabulary the file already
uses at its Step 7 checklist line ("schedule the next wake per the
cadence contract (§5.3)") and in
the sibling `babysit-loop` SKILL.md ("that mapping owns the seconds").

Docs-only. No behavior change, no script or engine change.
`source-control` `0.26.1` → `0.26.2`
with the matching CHANGELOG entry.

## Test plan

Every command below was run from the branch worktree; all passed.

- `plugins/skill-quality/scripts/check-skill.sh babysit-prs`
(`CHECK_SKILL_BASE_REF=origin/main`) —
**exit 0**, `PASS — 0 errors`. This is the gate CI runs via
`scripts/check-changed-skills.sh`.
Includes the 500-line hard cap, all 9 base-ref trigger phrases
preserved, broken-internal-ref
  check, and `scripts/engine.test.sh`.
- **SKILL.md is now 497 lines** (base `origin/main`: 499). An
intermediate revision of this branch
landed at exactly 500 and *failed* the hard cap (`LINE_COUNT >=
LINE_HARD_CAP`, cap 500) — caught
by an independent reviewer before the PR was opened. Collapsing step 9
to a single pointer and the
References bullet back to two lines both fixed the cap and made the diff
better follow
pointer-not-copy. The one remaining `WARN` is the pre-existing 200-line
soft target, untouched by
    this change.
- `scripts/check-changelog-parity.sh --check` — exit 0.
- `scripts/check-changelog-parity.sh --check-bump origin/main` — exit 0
(the manifest bump has a
  matching `## [0.26.2]` entry).
- `markdownlint-cli2` over both changed markdown files, repo config
auto-discovered — exit 0,
  0 errors.
- `jq empty` on `plugins/source-control/.claude-plugin/plugin.json` —
exit 0.
- `typos` over both changed markdown files — exit 0.
- Every relative link in the edited regions resolved by hand
(`reference/loop.md`, `cadence.md`,
`safety.md`, `orchestration.md`, `freshness.md`, `stuck-checks.md`,
`feedback.md`,
`review-trigger.md`); the `§5.3` anchor exists at
`reference/loop.md:426`. No external URLs
  introduced, so lychee's absence changes nothing.
- Repo-wide `grep` for any remaining reference sending a reader to
`cadence.md` for wake seconds, or
describing §5.3 as holding only the static ladder — none remain. The
four surviving `cadence.md`
citations in `plugins/source-control/` were each checked and are
correct.

Claims verified against the actual files rather than the issue text:
§5.3 does contain both the
mapping table (`loop.md:435-440`) and the degrade ladder
(`loop.md:456-462`); `cadence.md:4-5` does
disclaim the wake mechanics; `cadence.md:27` does state `idle` = daily
against `loop.md:449-452`'s
`[60, 3600]` clamp. The CHANGELOG entry's causal account was corrected
after `git show --stat
e9cef6e` showed #652 never touched `cadence.md`, and `git log -L4,5`
dated that file's disclaimer to
`fe78acb6` (#322) — so the references were always imprecise; #652 only
made the correct target
concrete.

Per this repo's fresh-docs mandate,
<https://code.claude.com/docs/en/skills> was fetched this
session. It confirms the progressive-disclosure model this diff operates
on — SKILL.md body plus
bundled supporting files loaded on demand — and imposes no constraint on
how a SKILL.md cites its
own `reference/` files, so the choice of pointer is a repo-convention
question, decided above by
`CLAUDE.md`'s pointer-not-copy rule.

## Related

- Refs #652 / #504 — the PR and issue that added the §5.3 mapping table
this diff points at.
- Refs #322 — where `reference/cadence.md` first disclaimed ownership of
the wake mechanics.
- Version note: open PRs #1285 and #1264 both claim `source-control`
`0.27.0`. If either merges
first this branch's `0.26.2` becomes a regression — it self-surfaces as
a git conflict on both the
manifest line and the CHANGELOG insert point, so rebase rather than
force-merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Declined: a Flag row type for accept/reject asymmetry

A local-only commit (e7174715aa, "feat(source-control): add the Flag row type for accept/reject asymmetry") existed on this branch's worktree and has been dropped rather than pushed. Recording the reasoning so the decision is reviewable rather than silent.

What it was. A 17-line frozen Flag dataclass in scripts/tests/guard_contract.pyid, claim, entry_point, flag, accepted — for cases where a flag's presence or absence is itself the contract (a wrapper filtering a flag its CLI never accepts is dead code; a flag on one entry point but not the other is the asymmetry consumers restate wrongly).

Why it is declined, not lost.

  1. It was never dropped by anyone — it was authored on a stale base. e7174715aa and this branch's head 4bf9eb97ba are siblings, both parented on 5515b19d37. 4bf9eb97ba was authored 07:08Z, e7174715aa at 14:12Z — seven hours later. So the scope narrowing in 4bf9eb97ba did not remove the Flag type; it never saw it.
  2. The type has zero rows and zero references. class Flag is defined once and never instantiated. Nothing in the row tables, the runner, or the markdown generator reads it. Landing it adds a declaration CI does not exercise — the opposite of what Guard semantics the lane's consumers restate in prose have no drift detection #1265 asks for, which is that every published claim be bound to an executable assertion.
  3. It contradicts documentation it leaves in place. reference/guard-contract.md:108 still reads "Flag names, types, and defaults are not catalogued", and the deferral note in guard_contract.py still stands. A type with no rows does not change that, so the doc would be correct and the code would be decoration.

Recoverability. Preserved locally at tag salvage/1265-flag-row-type (and in reflog). It is 17 self-contained lines; recreating it alongside its first real rows is cheaper than carrying an unused declaration through review.

Trigger to revisit: when a concrete accept/reject asymmetry needs asserting — the --allow-unpinned-head bash-vs-argparse split named in #1265 is the obvious first row. At that point the type arrives with rows that exercise it.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Independent verification — VERDICT: PASS-WITH-FINDINGS

Audited by a fresh context that did not write this branch, given the artifact and #1265's requirement only, rationale withheld. The requirement is met in substance: all four fact families #1265 names are present, and the two that were gotten wrong three times — resolver flag semantics and refusal location (bash wrapper vs Python CLI) — are genuinely executably bound. Findings are over-claim and coverage-hole tier.

CRITICAL 1 — the generated doc over-claims about itself

reference/guard-contract.md:7-9: "Every statement below is executed as an assertion by scripts/tests/test_guards.py".

The entry-point table's Gate and Mutates columns are asserted by nothing. EntryPoint.gate (guard_contract.py:137) and EntryPoint.mutates_what (:136) are read by no test — test_guards.py reads only .path, .wrapper, .mutation, .backed_by. The Not covered here section (:106-109) discloses the flag-catalogue and live-GitHub limits but not this one.

Failure scenario: a consumer writes a permission rule citing snapshot.write-state-gates-the-only-write believing CI proves --write-state is the only path that reaches it. CI proves two substrings coexist in a file. That is the exact failure mode this PR exists to close, reproduced inside it. Same class, render-only: Refusal.enforced_at (:72) — contrast Predicate.enforced_at (:89), which test_anchor_symbol_exists does verify.

CRITICAL 2 — "which helpers mutate" is behavior-bound for one script only

All three EFFECTS rows are LEASE_CLI (guard_contract.py:649-696). prune_babysit_worktrees.py, pr_queue_snapshot.py, request_review.py, refresh_pr_branch.py, and manage_feedback_ledger.py's write path are bound only by MECHANISMS substring presence (test_guards.py:227-239, assertIn(token, source)).

Confirmed by mutation testing on a scratch copy of the plugin — three guard-destroying edits, applied simultaneously, all survive green (Ran 14 tests in 26.748s / OK):

Mutation Row that should have caught it Result
prune_babysit_worktrees.py:243 if eligible and args.apply: changed to if eligible: (dry-run default destroyed; git worktree remove runs flagless) prune.removes-worktrees-via-git"Its dry-run default is what makes the flagless form safe" survived
pr_queue_snapshot.py:322 — a second, ungated state_store.save_state(...) inserted above the guarded one snapshot.write-state-gates-the-only-write"--write-state is the only thing that reaches it" survived
request_review.py:163 if not args.apply: changed to if False: review.posts-a-comment survived

IMPORTANT 3 — the checker cannot catch the one flag the wrapper refuses

test_guards.py:334-370 validates documented flags against the backing CLI's --help. babysit_merge.py accepts --allow-unpinned-head; only bin/source-control-babysit-merge:15-22 refuses it. Appending a documented wrapper command carrying that flag to reference/safety.md gave Ran 14 tests in 45.742s / OK. An operator copying that line gets exit 2. This is the refusal-location fact #1265 says consumers restated wrongly.

IMPORTANT 4 — the doc sweep misses the spelling the docs use most

WRAPPER_COMMAND (test_guards.py:307-309) requires the literal bash "${CLAUDE_PLUGIN_ROOT}/bin/…" form, and SKILL.md is not in DOC_COMMAND_SOURCES at all. Consumer-facing flag prose already exists in the bare-name form and is unchecked — SKILL.md:191, SKILL.md:216, and reference/safety.md:187-188 (inside a document that is a DOC_COMMAND_SOURCES row). Appending source-control-babysit-merge owner/repo#N --allowed-owners <o> --totally-fake-flag to SKILL.md leaves the suite OK.

IMPORTANT 5 — the one READ_ONLY classification is backed by nothing, silently

guard_contract.py:893-901 classifies babysit_findings.py as READ_ONLY with backed_by=(), and test_every_mutating_entry_point_is_backed (test_guards.py:296-304) continues past READ_ONLY. The strongest claim a permission classifier acts on — "safe to grant unconditionally" — is unasserted. It is true today (no save_state, write, subprocess, or gh_json in the file); it is just unguarded against becoming false.

IMPORTANT 6 — silent-skip holes leave the asymmetry unasserted

test_guards.py:117-118 calls skipTest("bash unavailable; wrapper rows need it"), skipping every bin/ row when shutil.which("bash") is None — a green suite asserting nothing about bash-vs-Python refusal location. engine.test.sh:21-24 can no-op the whole suite on missing Python 3.11+. The repo's own scripts/check-silent-skips.sh does not reach these: its scope is plugins/*/hooks/*.sh entry scripts.

SUGGESTIONS

  • contract.PYTHON_CLI (guard_contract.py:54) is never used as a comparison target; test_guards.py:143-161 branches on == BASH_WRAPPER and everything else takes the else path. refused_by is unchecked on 20 of 22 rows.
  • merge.owner-check-precedes-self-login-resolution (:190-208) claims "with no gh invocation at all"; the test observes only exit code and inScope: False. True today (parse_allowed_owners at babysit_merge.py:901, refusals at :915 and :949, resolve_authors only at :1008) — a reordering would not fail CI.
  • refresh.uses-server-side-update-branch uses a must_not_contain of the quoted literal push (:715) — that catches a quoted literal in one file; a push reached via an imported helper passes.
  • The deferral note (:19-24) says parsers are built inside main() across "all nine" entry points. babysit_findings.py:136-137 already has a standalone parse_args(argv), so it is 8 of 9 — and test_guards.py:334-341 already recovers flag names by --help introspection with no build_parser() extraction, so the stated blocker is weaker than claimed.
  • guard-contract.md:76 (from :1036) references "seeded with Fixture"; there is no Fixture class — Effect.fixture is a plain str.
  • engine.test.sh:61-71 still runs four bash-level wrapper checks that now duplicate contract rows (merge.unpinned-head-refused-by-wrapper, merge.wrapper-reaches-failclosed-cli, resolve.wrapper-reaches-failclosed-cli, merge.autopilot-tier-without-required-sets). Rows were added without retiring the older copies — two places to update.
  • test_every_executable_script_is_classified (:243-258) keys off the substring __main__ and a non-recursive SCRIPTS.glob("*.py"); an entry point in a subdirectory escapes the catalogue gate.

Under-claiming vs #1265

Named in the issue, not covered by an executable assertion: request_review.py --apply posting a comment (substring only); prune_babysit_worktrees.py --apply running git worktree remove (substring only, mutation-confirmed unbound); the ledger helper's write path (only a refusal row, never an observed state change); refresh_pr_branch.py requesting update-branch rather than pushing (substring plus the weak negative above); babysit_merge.py's documented exit 0 ready / 10 not ready (:788, in the doc, no row); #571 displacement (prose inside a claim, not a row).

Also not delivered and not recorded as a deferral: the machine-readable manifest half of option 1 — the artifact emits markdown only, so /source-control:setup's #787 reachability probe still names lane scripts from prose.

Dead code — explicit answer

Zero unreferenced definitions. Every dataclass, constant, and helper in guard_contract.py is referenced from that module or test_guards.py. uvx ruff check . tests gave All checks passed!. vulture flagged plugin_path, envelope_fields, must_contain, must_not_contain, WRAPPER_BACKING_CLI — all isolation artifacts, used from test_guards.py. Not findings. The separate category is "defined and rendered into the doc but never asserted": Refusal.enforced_at, EntryPoint.mutates_what, EntryPoint.gate, EntryPoint.claim.

What actually ran

  • python -m pytest .../tests/ -q gave No module named pytest. pytest is not a dependency of this suite.
  • uvx --with pytest pytest .../tests/ -q gave 348 passed, 135 subtests passed in 35.26s
  • The actual CI path (ci.yml to run-plugin-tests.sh to engine.test.sh:29) python -m unittest discover -s tests -p 'test_*.py' gave Ran 348 tests in 25.930s / OK
  • Live-gh check: a logging shim placed first on PATH, full suite gave Ran 348 tests in 81.633s / OK, with the call log containing only the sanity gh --version. No test needs network or gh auth.
  • scripts/check-changelog-parity.test.sh (the gate's own self-test) gave PASS=25 FAIL=0, exit 0.

Version queue

plugin.json moves 0.26.0 to 0.27.0; CHANGELOG.md:6 gains ## [0.27.0] above ## [0.26.0], Keep-a-Changelog ordering intact. marketplace.json carries no per-plugin version, so nothing to keep in sync. Note that origin/main is now at 0.26.2, and #1264 also claims 0.27.0 — one of the two has to move. Landing order is #1264 first, then this.

Not verified

scripts/check-changelog-parity.sh --check and --check-bump origin/main did not complete in this environment. The two parity inputs were read directly and agree, and the gate's own self-test passes, but the parity gate was not exercised against this branch — reported as not run, not as passing.

No files were edited, committed, or pushed; the scratch tree was removed and the worktree is clean at 4bf9eb97ba.

The preamble promised "Every statement below is executed as an assertion
by scripts/tests/test_guards.py". Four rendered columns are not: the
entry-point table's Mutates, Gate, and Claim, and the refusal table's
Enforced at. test_guards.py reads entry.path, entry.wrapper,
entry.mutation, and entry.backed_by and nothing else from an EntryPoint,
and row.enforced_at only over PREDICATES, so Refusal.enforced_at is
unread.

A consumer writing a permission rule against those columns believed CI
proved them. That is the failure mode this contract exists to close, so
publishing it here was self-refuting.

The preamble now names which tables are executed, and "Not covered here"
gains the four columns with what IS asserted about entry-point rows, so
the disclosure is usable rather than a bare disclaimer.

Binding those columns to real assertions is a separate change; this one
stops the document overstating what CI proves today.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Version queue: main reached 0.26.4 and PR #1264 holds 0.27.0 ahead of this
branch, so this section moves to 0.28.0. Landing order is #1264 then this.

One content conflict, in the plugin CHANGELOG, where both sides added a new
top section. Both are kept, newest first. No source file conflicted.

Verified after the merge: `python -m unittest discover -s tests` 366 OK
(the suite grew with main's arrivals), the generated guard contract
regenerated from guard_contract.py and unchanged by the merge, markdownlint
clean on both changed documents.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 47s —— View job


PR Review: feat(source-control): bind babysit guard semantics to an executable contract

Tasks

  • Gather context and understand the PR
  • Check latest commits since prior review (7b3ea15, 2221a4e)
  • Review changed files at HEAD
  • Assess flag-abbreviation fix against prior IMPORTANT 3 finding
  • Check open findings from independent verification
  • Synthesize and post findings

What the latest commits addressed

The merge commit 7b3ea15 rebases onto main at 2221a4e (version 0.26.4), advancing this branch to 0.28.0 per the declared landing order. The merge resolved one CHANGELOG conflict — both sides added a top section — and both are kept, newest first. guard-contract.md regenerated unchanged.

0689a1d fixes CRITICAL 1 from the independent verification: the preamble no longer promises "every statement below is executed." It now names which tables are CI-asserted (refusals, predicates, effects, documented commands) and adds four specific columns to "Not covered here" (entry-point Mutates, Gate, Claim, and refusal Enforced at). The disclosure is now accurate.

2221a4e (from main, PR #1354) closes the Codex P1 finding: both babysit_merge.py and babysit_resolve_thread.py parsers now set allow_abbrev=False, and the merge wrapper's refusal loop now uses [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]] rather than exact equality. Both changes are correctly documented in their sources.

All four findings from the prior review round (false state-write claim, lock-file scope, snapshot "pure read", multi-command parser) remain fixed. The contract is factually accurate on all previously-wrong claims.


New findings from the merge (things the contract does not yet cover)

1. Severity-marking guard added by main is tested but has no contract row (IMPORTANT)

2221a4e added a new guard in classify() at babysit_resolve_thread.py:253:

if autonomous and thread.get("severityFlagged", True):
    return "skipped-severity-marked"

Under --autonomous, a thread whose projected comments carry a structured severity marker (CRITICAL/IMPORTANT uppercase, P0–P3 shield badge, bracketed [P0][P3], or the word "security") is skipped rather than resolved. A truncated comment page also triggers this guard via the fail-closed default (get("severityFlagged", True)). This behavior is covered by test_babysit_resolve_thread.py::SeverityMarkedThreadsSkippedInAutonomousMode (four test cases).

There is no corresponding predicate row in guard_contract.py. The PREDICATES table covers the isOutdated guards for --autonomous but has nothing documenting the severity gate. A consumer writing a permission rule citing "autonomy only resolves addressed, non-current threads" has a CI-proven claim. A consumer writing a rule about "autonomy skips severity-marked threads" has no row to cite — and could not know from guard-contract.md that this guard exists at all.

The fail-closed default is the most rule-relevant fact: a flagless invocation against a thread whose comment page was truncated behaves as if severity-marked. This is exactly the kind of non-obvious guard behavior the contract exists to document.

The existing autonomous-mode predicate rows (classify.autonomous-requires-outdated, classify.include-human-does-not-lift-outdated) project threads without severityFlagged, but they pass because the isOutdated skip fires before the severity check — they don't exercise or prove the severity gate.

Minimum fix: add a predicate row with thread={"isResolved": False, "botOnly": True, "isOutdated": True, "severityFlagged": True} and flags={"autonomous": True, ...} expecting "skipped-severity-marked", and a second row for the fail-closed default (omit severityFlagged from the thread dict, expect "skipped-severity-marked"). The test_anchor_symbol_exists test will then pin _has_severity_marker as an enforced_at anchor.

Fix this →


2. Wrapper prefix-catch fix is not bound by any contract assertion (IMPORTANT)

The wrapper now checks:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]; then

The existing refusal row merge.unpinned-head-refused-by-wrapper tests only with --allow-unpinned-head (exact form). The mechanism row merge.wrapper-filters-unpinned-head-in-bash checks must_contain=('for arg in "$@"', "--allow-unpinned-head") — both tokens are present whether the wrapper uses exact equality or prefix match.

If the wrapper reverted to exact equality (removing the "--allow-unpinned-head" == "$arg"* half), both the refusal row and mechanism row would pass. The Codex P1 finding was specifically that an abbreviated prefix like --allow-unpinned-hea bypasses the exact check; the fix for that is now unbound by CI.

Minimum fix: add a second refusal row with argv=("owner/repo#1", "--merge", "--allow-unpinned-hea") expected to exit 2 via BASH_WRAPPER (the nine-character prefix is unambiguous — there is no other --allow-unpinned-* flag). Or update the mechanism row's must_contain to include the prefix predicate string '"--allow-unpinned-head" == "$arg"*'.

Fix this →


Still-open items from independent verification (none are blockers)

The independent verification's CRITICAL 2 (mutation testing surviving for prune, snapshot, review, refresh) remains true: those entry points are covered only by MECHANISMS substring checks, not EFFECTS invocation rows. This is now a disclosed gap — the "Not covered here" section correctly says the Mutates and Gate columns are annotation, not proof. The practical risk to a consumer is lower now that the disclosure is accurate.

The following minor items from prior review rounds are still open:

Finding Location
heartbeat/release write unconditionally; no effect row and no "Not covered here" note guard_contract.py LEASE_CLI entry
babysit_findings.py READ_ONLY classification backed by nothing guard_contract.py L893
SKILL.md's bare-name wrapper commands (source-control-babysit-merge owner/repo#N …) are not covered by WRAPPER_COMMAND regex or DOC_COMMAND_SOURCES — a renamed flag there is not caught SKILL.md:191
resolve.allowlist-absent has empty error_contains; asymmetric vs merge.allowlist-absent guard_contract.py L333
refused_by for non-bin/ rows never validated against BASH_WRAPPER/PYTHON_CLI test_guards.py L143
"__main__" in text looser than 'if __name__ == "__main__"' in text test_guards.py L250
seed() parses proc.stdout without .strip() test_guards.py L93
arg.format(state_dir=tmp) uses full format mini-language test_guards.py L120, L208

Summary

The two new findings above are the most actionable ones before merge. Finding 1 is the more important: the severity-marking guard is the kind of non-obvious behavioral gate the contract exists to document, and its fail-closed default (severityFlagged missing → treated as marked) is exactly the class of fact consumers get wrong. Finding 2 closes the last inch of the Codex P1 fix — the wrapper does the right thing now, but nothing proves it. Both have one-row fixes.

The contract's core structure is sound. The disclosure fix in 0689a1d is the right architectural response to CRITICAL 1 — accurate scoping beats a false completeness claim. CHANGELOG, version bump (0.26.40.28.0), and guard-contract.md regeneration are all consistent.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 20s —— View job


Security Review — PR #1285

Tasks

  • Gather context and understand request
  • Read PR diff and changed files
  • Review guard_contract.py for security issues
  • Review test_guards.py for security issues
  • Review reference docs and other changed files
  • Post security findings

Scope

All 6 changed files reviewed at HEAD (7b3ea15), including the two commits that have landed since the previous security review (0689a1d, 7b3ea15 merge):

  • plugins/source-control/.claude-plugin/plugin.json — version bump only
  • plugins/source-control/CHANGELOG.md — documentation only
  • plugins/source-control/skills/babysit-prs/reference/guard-contract.md — generated doc
  • plugins/source-control/skills/babysit-prs/reference/safety.md — 7-line docs addition
  • plugins/source-control/skills/babysit-prs/scripts/tests/guard_contract.py — new: contract tables + markdown emitter
  • plugins/source-control/skills/babysit-prs/scripts/tests/test_guards.py — modified: contract runner

No CRITICAL or IMPORTANT security issues found.

This is test infrastructure. All subprocess calls use the list form throughout — no shell=True, no command injection surface. No secrets, credentials, or tokens are handled. No GitHub Actions workflows are modified. No external dependencies introduced.

The two IMPORTANT factual errors in the contract document (babysit_merge.py falsely claimed to write queue state; snapshot.write-state-gates-the-only-write claimed a flagless invocation was a pure read) were fixed in 4bf9eb9. The preamble's overclaim that "every statement below is executed as an assertion" — flagged as CRITICAL 1 in the independent review — was corrected in 0689a1d, which now precisely names which columns are executed and which are annotation only.

The flag-abbreviation bypass (the only IMPORTANT 3 security gap named in the independent review — --allow-unpinned-hea sailing past the bash wrapper) was fixed in 2221a4e (PR #1354), merged into this branch at 7b3ea15. The fix adds allow_abbrev=False to both parsers and replaces the bash wrapper's exact-equality check with a prefix-aware match.


SUGGESTION — arg.format(state_dir=tmp) uses Python's full format mini-language

Severity: SUGGESTION Confidence: medium

test_guards.py line 120 and line 208:

argv = tuple(arg.format(state_dir=tmp) for arg in row.argv)

arg.format(state_dir=tmp) invokes Python's full format mini-language rather than a literal string replacement. All current argv tuples in guard_contract.py are hardcoded literals — no existing row is exploitable. However, a future contributor adding an argv row with an unintended {placeholder} gets a KeyError at test runtime (or, if a placeholder accidentally matches an attribute path, a silently wrong value substituted into the subprocess argv). arg.replace("{state_dir}", tmp) is strictly safer and equally expressive for this use case.

Fix this →


SUGGESTION — seed() reads lease_path from subprocess stdout without bounding it to the temp dir

Severity: SUGGESTION Confidence: low

test_guards.py lines 93–97:

lease_path = pathlib.Path(str(json.loads(proc.stdout)["path"]))
record = json.loads(lease_path.read_text(encoding="utf-8"))
...
lease_path.write_text(json.dumps(record), encoding="utf-8")

lease_path is taken directly from manage_babysit_lease.py's stdout, without asserting it lies inside the test's temporary directory. If that script ever had a path-traversal bug in its output, seed() would follow the path and overwrite an arbitrary file on the CI host. At present the --state-dir argument points at the tempdir so the output should be inside it — this is a latent concern, not a current vulnerability. Adding lease_path.relative_to(state_dir) (which raises ValueError if the path escapes) after line 93 would make the invariant explicit.

Fix this →


Observation — abbreviated-flag refusal is enforced but not asserted in the contract

This is not a current security gap, but worth noting for completeness. The merge.unpinned-head-refused-by-wrapper contract row was authored before the flag-abbreviation fix (PR #1354) and asserts refusal of the exact spelling only:

argv=("owner/repo#1", "--merge", "--allow-unpinned-head"),

The merged fix correctly closes the abbreviated-form bypass at both layers (allow_abbrev=False on the Python parser; the bash wrapper now uses [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]). The property the row claims ("no allow-rule-covered invocation can merge an unvetted head") is therefore true, including for abbreviated inputs. But the contract has no row exercising the abbreviated-form rejection specifically. A consumer auditing the contract for prefix-bypass resistance would not find it bound to an assertion.


Positive observations

  • All subprocess calls use the list form throughout test_guards.py. No shell=True anywhere in the changed files.
  • WRAPPER_COMMAND regex limits the wrapper name capture to [a-z-]+, preventing path traversal in wrapper names extracted from markdown documents.
  • test_scope_refusal_precedes_every_network_call empties PATH to prove no subprocess reaches gh — the right way to assert network-free behavior rather than mocking.
  • The 0689a1d preamble fix now accurately discloses which columns are executed assertions and which are annotation only, eliminating the prior CRITICAL finding about the document over-claiming CI proof.
  • plugin_path() resolves entirely from __file__ with no user-supplied path components.
  • The entry-point table preamble explicitly tells classifiers that gate-less invocations still need write access inside --state-dir for housekeeping, preventing a false "read-only" permission grant.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b3ea15223

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +334 to +341
def _accepted_flags(self, cli: str) -> set[str]:
proc = subprocess.run(
[sys.executable, str(contract.plugin_path(cli)), "--help"],
capture_output=True,
text=True,
)
self.assertEqual(code, 2)
self.assertIn("expected-comment-count", payload.get("error", ""))
self.assertIn("expected-last-updated", payload.get("error", ""))

def test_autonomous_bulk_resolve_without_thread_id_is_refused(self):
# The bug this guard closes: an unattended worker's own push marks a
# thread isOutdated, and a bulk (no --thread-id) autonomous resolve would
# clear it with no proof the finding was addressed. Refused before any
# network fetch -- the fix-closed contract the docs already describe.
code, payload = run(
RESOLVE, "owner/repo#1", "--allowed-owners", "owner",
"--autonomous", "--resolve",
self.assertEqual(proc.returncode, 0, f"{cli} --help failed: {proc.stderr}")
return set(re.findall(r"--[a-z0-9][a-z0-9-]*", proc.stdout))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive accepted flags from parser option strings

Scraping the entire --help output treats option names mentioned only in prose as accepted flags. For example, babysit_merge.py --help mentions the prohibited --admin, so _accepted_flags() includes it even though invoking the CLI with --admin exits 2 with “unrecognized arguments”; a documented command containing that stale flag would therefore pass this contract despite being unusable. Inspect the parser's registered option strings or actually parse each documented command instead.

Useful? React with 👍 / 👎.

Comment on lines +247 to +250
present = {
f"skills/babysit-prs/scripts/{path.name}"
for path in contract.SCRIPTS.glob("*.py")
if "__main__" in path.read_text(encoding="utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the documented readiness entry point in the catalogue

The completeness scan only considers Python files under skills/babysit-prs/scripts, but the skill directly invokes the product entry point plugins/source-control/scripts/babysit-readiness-gate.sh in SKILL.md:349. Consequently that existing shell entry point is absent from the generated mutation catalogue, and any future babysit shell entry point outside this one Python directory will also evade the advertised completeness gate.

Useful? React with 👍 / 👎.

Comment on lines +296 to +303
def test_every_mutating_entry_point_is_backed(self) -> None:
for entry in contract.ENTRY_POINTS:
if entry.mutation == contract.READ_ONLY:
continue
with self.subTest(entry=entry.path):
self.assertTrue(
entry.backed_by,
f"`{entry.path}` is classified {entry.mutation} with nothing asserting it",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify mutation classes rather than row presence

When an existing entry point changes from read-only to mutating, or from conditional to unconditional mutation, this check remains green because it only requires some backed_by ID and never verifies that the cited row proves the declared class. Fresh evidence in this final revision is that guard-contract.md explicitly describes the Class column as asserted while excluding only Mutates, Gate, and Claim; consumers can therefore rely on a class that CI does not actually bind to behavior.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Guard semantics the lane's consumers restate in prose have no drift detection

1 participant