Skip to content

feat(ci): refuse a proposal that is unrunnable the moment it is committed - #1210

Merged
mashkovd merged 4 commits into
mainfrom
feat/validate-agents-state-approval
Sep 12, 2026
Merged

mashkovd merged 4 commits into
mainfrom
feat/validate-agents-state-approval

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Closes #1207.

The hole

A .status.yaml that reads status: accepted while demanding a human approval it does not carry can never be implemented by any supported path. human_approval_satisfied() correctly refuses it, and the approve operation used to short-circuit on anything already accepted without recording an approver.

Nothing fails. Both sides exit 0 and report success, so once merged the condition is invisible. mctl-design/issue-21-docs-license-file-reference-in-readme sat that way from 2026-08-05 until someone went looking on 2026-09-12 — five weeks, written by claude-code-e2e-verification directly as:

status: accepted
updated_by: claude-code-e2e-verification
control:
  requires_human_approval: true

mctlhq/mctl-agents#358 added a write-time guard there, but it binds only writers inside that repository. It cannot stop a tool committing YAML straight into this one — which is exactly how that proposal was created. This is the gate that can.

The predicate is borrowed, not invented

requires_human_approval() / has_named_approver() mirror human_approval_satisfied() in orchestrator/proposal_state.py and keep its fail-closed direction:

  • only a recognisably falsey value waives the gate — a quoted "true" is truthy, and anything unrecognised requires approval rather than being read as consent (both were review findings over there)
  • an absent control block means "never asked" — the incident-responder writes accepted with no control block and must keep passing
  • a control block present but not a mapping is corrupt, not absent, so it fails closed

The docstring says plainly that these move together. Reimplementing the rule freehand is how it would drift, which is what #1207 warned about.

Strict from day one

origin/main is clean — all 309 proposals pass. The single historical violation was repaired by #1208 before this landed, so no unrelated PR inherits a red build. No warn-only mode needed.

Proven in both directions

A guard that has only ever been seen to pass is not known to work, so all three were run:

--selftest drives the real check_dir() over ten throwaway fixtures and asserts each: 4 rejected (bare gated, quoted "true", anonymous approved_by: unknown, corrupt control), 6 accepted (named approver, no control block, false, "no", plus proposed and implemented — states this validator has no business judging)
real violation restoring issue-21's historical shape in the tree makes the real pass exit 1 and name the file
broken predicate making an absent control block require approval makes --selftest exit 1 with wrongly flagged accepted_no_control_block — the incident-responder regression it exists to prevent

The selftest drives the shipping code path rather than a copy of it, same as check-vm-rules.sh --selftest and validate-origin-mtls.py --selftest, and it is wired to run before the real pass in validate-manifests.yml.

Error output

::error file=...issue-21.../.status.yaml::mctl-design/proposals/issue-21-.../.status.yaml: status is 'accepted' and control.requires_human_approval is set, but no approval.approved_by names a real approver

1 proposal(s) cannot be implemented by any supported path.

Neither side of the pipeline fails on this -- the approve operation and the
implementer both exit 0 -- so it is invisible once merged.

Fix by publishing the proposal as `status: proposed` and approving it, which
is what records `approval.approved_by`. Do NOT hand-write that field: it
forges the exact record the gate exists to require (gitops#986).

The remedy names the supported recovery and explicitly refuses the forgery, because hand-writing approved_by is the tempting wrong answer.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U

…tted

Closes gitops#1207.

A `.status.yaml` that reads `status: accepted` while demanding a human
approval it does not carry can never be implemented by any supported path.
mctl-agents' human_approval_satisfied() correctly refuses it, and the approve
operation used to short-circuit on anything already accepted without recording
an approver. Both sides exit 0 and report success, so once merged the condition
is invisible -- mctl-design/issue-21 sat that way for five weeks before anyone
looked (mctl-agents#349).

mctl-agents#358 added a write-time guard, but it binds only writers inside that
repository. It cannot stop a tool committing YAML straight into this one, which
is exactly how the stuck proposal was created. This is the gate that can.

The predicate mirrors human_approval_satisfied() rather than inventing its own,
and keeps its fail-closed direction: only a recognisably falsey value waives the
gate, an absent `control` block means "never asked" -- the incident-responder
writes `accepted` with no control block and must keep passing -- and a `control`
block present but not a mapping is corrupt rather than absent. Those semantics
were hardened twice by review over there; the docstring says to change both
together if they move.

Shipped strict rather than warn-only, because origin/main is clean: all 309
proposals pass. The one historical violation was repaired by gitops#1208 before
this landed, so no green build turns red for a reason unrelated to the PR that
trips it.

Proven in both directions, not just observed to pass:

  - --selftest drives the real check_dir() over ten throwaway fixtures and
    asserts on each: four unrunnable shapes rejected (bare gated, quoted
    "true", anonymous approver, corrupt control), six legitimate ones accepted
    (named approver, no control block, false, "no", and the proposed /
    implemented states this validator has no business judging).
  - Restoring issue-21's historical shape in the real tree makes the real pass
    exit 1 and name the file.
  - Breaking the predicate so an absent `control` block requires approval makes
    --selftest exit 1 with "wrongly flagged accepted_no_control_block", which
    is the incident-responder regression it exists to prevent.

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 14s —— View job


Review of PR #1210 — complete

  • Gather context (PR diff, description, CLAUDE.md conventions)
  • Review scripts/validate-agents-state-approval.py
  • Review .github/workflows/validate-manifests.yml changes
  • Post findings / verdict

Verdict: APPROVED (1 P3, no P1/P2)

Reviewed git diff f1c8d570c7a79ebd42488d8ba84c40b3e9e299e7...88cfb1a665d4c450136432ca3417f5af3ad3694d directly against the pinned commit.

What I checked:

  • Predicate logic (requires_human_approval, has_named_approver, unrunnable): correctly fail-closed — absent control → not gated (incident-responder shape preserved), control present-but-not-a-mapping → corrupt → flagged, string/int falsey handling matches the documented FALSEY set, anonymous approvers (unknown/none/null/empty) rejected.
  • Glob coverage: agents_state.glob("*/proposals/*/.status.yaml") — I independently counted .status.yaml files under platform-gitops/agents-state with find and got exactly 309, matching the PR's "all 309 proposals pass" claim, so the pattern isn't silently skipping any deeper-nested files.
  • Workflow wiring: the new step runs --selftest before the real pass (matching the repo's existing detector convention) and relies on pyyaml being installed by an earlier unconditional step in the same job (same pattern already used by validate-openclaw-version-pin.py and validate-local-workdir.py), so no new dependency gap.
  • Security: uses yaml.safe_load, only reads repo-local files, no injection surface.

Finding posted inline (P3, non-blocking): the module docstring at scripts/validate-agents-state-approval.py:23 references a test_parity_with_mctl_agents case as the thing to update if the mirrored predicate in orchestrator/proposal_state.py changes, but no such case/test exists anywhere in the file — the actual self-test cases are named things like accepted_gated_no_approval. Worth fixing so the docstring doesn't point future maintainers at something that isn't there.

gate, an absent `control` block means "never asked", and a `control` block
that is present but not a mapping is corrupt rather than absent. Those
semantics were hardened twice by review; if they change there, change them
here -- `test_parity_with_mctl_agents` in the self-test names the contract.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The module docstring says test_parity_with_mctl_agents in the self-test names the contract to keep this predicate in sync with orchestrator/proposal_state.py, but no such test/case exists anywhere in this file — SELFTEST_CASES uses names like accepted_gated_no_approval etc. Either add a case with that name (or a comment referencing the real one) or fix the docstring so it doesn't point future maintainers at something that isn't there.

@claude claude 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.

No P1/P2 findings (1 P3). Predicate logic correctly mirrors the fail-closed semantics described (absent control = never asked, non-mapping control = corrupt/fails closed, string/int falsey values recognized, anonymous approvers rejected). Glob pattern */proposals/*/.status.yaml matches all 309 existing .status.yaml files under platform-gitops/agents-state (verified via find), so the real pass covers the full tree as claimed. Self-test drives the shipping check_dir() over throwaway fixtures, consistent with the repo's other --selftest detectors. Good to merge.

@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: 88cfb1a665

ℹ️ 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 +46 to +48
control = data.get("control")
if control is None:
# No control block at all means the proposal never asked. The

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 Distinguish a null control block from an absent one

When an accepted status file contains control: null, yaml.safe_load() produces None, so this branch treats the explicitly present non-mapping block as if it were absent and the validator passes it. The downstream approval predicate will reject that corrupt proposal, recreating the silently unrunnable state this gate is intended to prevent; check whether the control key is absent before granting this exception.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Three of the four findings are fixed in 1c151f56. The fourth is answered rather than applied, because measuring it showed the premise does not hold.

Applied

Codex P1 — shape before approver. Correct, and it was the serious one. unrunnable() returned early on has_named_approver() before checking the shape of control, but human_approval_satisfied() fails closed on a non-mapping control before it looks at approval. So a named approver does not rescue that file over there — and the validator was calling an unrunnable proposal clean. Measured against orchestrator/proposal_state.py at mctl-agents c13df87:

{"status": "accepted", "control": "yes please",
 "approval": {"approved_by": "mashkovd"}}   ->  satisfied=False

Shape is checked first now.

Codex P2 — or {}. Correct. [], false, 0 and "" all became an empty mapping and sailed through the isinstance check. Only a genuinely empty document (None) is defaulted now; anything else non-mapping is a finding that names the type it got.

claude P3 — the docstring named a test that does not exist. Correct, and mine. It promised test_parity_with_mctl_agents; nothing of that name was ever written, which is worse than making no claim — it tells the next reader the parity contract is enforced when it is not. Replaced with a PARITY block of cases --selftest actually asserts.

Self-test goes from 10 cases to 14 (7 rejected, 7 accepted), and both new branches are mutation-proven:

mutation selftest says
restore approver-before-shape did not flag accepted_control_not_mapping_with_approver
restore or {} did not flag root_is_empty_list, did not flag root_is_false

Not applied — Codex P1 on control: null

When an accepted status file contains control: null … The downstream approval predicate will reject that corrupt proposal, recreating the silently unrunnable state this gate is intended to prevent.

It does not reject it. human_approval_satisfied() opens with:

control = data.get("control")
if control is None:
    return True

data.get("control") returns None for an absent key and for an explicit control: null — the two are indistinguishable at that line, so both are "never asked" and both are satisfied. Executed against the real function at c13df87:

{"status": "accepted", "control": None}   ->  satisfied=True   (runs)
{"status": "accepted"}                    ->  satisfied=True   (runs)

A control: null proposal is therefore runnable, not stuck, and flagging it would make this gate red on files the pipeline handles fine. The finding's own stated consequence — "recreating the silently unrunnable state" — is what would not happen.

Distinguishing absent from null here while mctl-agents cannot distinguish them there would also break the parity this validator exists to hold, which is the drift #1207 specifically warned about.

control: null is now pinned as an accepted case in the PARITY block with the measured value beside it, so the next reader does not have to re-derive this.

Happy to revisit if human_approval_satisfied() changes to separate the two — but then the fix belongs there first, and this follows.

findings: list[tuple[Path, str]] = []
for path in sorted(agents_state.glob("*/proposals/*/.status.yaml")):
try:
data = yaml.safe_load(path.read_text()) or {}

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 Preserve falsey YAML roots for the mapping check

When a status file parses as a falsey non-mapping value such as [], false, 0, or "", the or {} replaces that value with an empty mapping, so the following type check never reports the corruption and CI passes. Such a status file is unreadable as proposal state and can make the proposal disappear from the agent pipeline without the promised failure signal; default only a genuinely empty YAML document if that case is intended to be accepted.

Useful? React with 👍 / 👎.

Comment on lines +82 to +85
if has_named_approver(data):
return None
control = data.get("control")
if not isinstance(control, dict):

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 Validate the control shape before accepting an approver

When control is any non-null non-mapping value and approval.approved_by contains a name, requires_human_approval() correctly treats the control block as corrupt, but this early return then accepts the file before the non-mapping check below runs. The downstream predicate fails closed on that control shape regardless of the approval field, so this combination still creates an unrunnable proposal while CI remains green; reject malformed control blocks before considering the approver.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 88cfb1a.

No significant issues found.

…wing falsey roots

Three review findings, two from Codex and one from claude. All three real; a
fourth from Codex is answered on the PR rather than applied, because measuring
it showed the premise does not hold.

Codex P1 -- a corrupt control block plus a valid approver passed

unrunnable() returned early on has_named_approver() before it ever checked the
shape of `control`. But human_approval_satisfied() fails closed on a non-mapping
`control` BEFORE it looks at `approval`, so over there a named approver does not
rescue that file -- it stays blocked. The validator therefore called an
unrunnable proposal clean. Measured against orchestrator/proposal_state.py at
mctl-agents c13df87:

  {"status": "accepted", "control": "yes please",
   "approval": {"approved_by": "mashkovd"}}   ->  satisfied=False

Shape is now checked first, with the reason spelled out where the reader needs
it rather than in a commit nobody re-reads.

Codex P2 -- `or {}` hid every falsey non-mapping root

`yaml.safe_load(...) or {}` turned `[]`, `false`, `0` and `""` into an empty
mapping, which then sailed through the isinstance check and was reported clean.
Only a genuinely empty document (None) is defaulted now; anything else
non-mapping is a finding that names the type it got.

claude P3 -- the docstring named a test that does not exist

It promised `test_parity_with_mctl_agents`. Nothing of that name was ever
written, which is worse than no claim at all: it tells the next reader the
parity contract is enforced when it is not. Replaced with a PARITY block of
cases that --selftest actually asserts, each measured by calling
human_approval_satisfied() rather than read off it.

Self-test grows from 10 cases to 14 -- 7 rejected, 7 accepted -- and both new
branches are mutation-proven:

  restore approver-before-shape -> "did not flag
      accepted_control_not_mapping_with_approver"
  restore `or {}`               -> "did not flag root_is_empty_list",
                                   "did not flag root_is_false"

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 46s —— View job


I'll analyze this and get back to you.

@claude claude 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.

No P1/P2 findings (0 P3). All three prior Codex/claude findings on 88cfb1a (control-shape-before-approver ordering, falsey-root swallowed by or {}, and the docstring referencing a nonexistent test) are fixed in 1c151f5 and covered by the grown self-test (10->14 cases, mutation-proven per commit message). Traced requires_human_approval/has_named_approver/unrunnable/check_dir by hand: shape check now runs before the approver check, only a genuinely-empty YAML document is defaulted to {}, and the null-control parity claim (control: null -> not gated) matches the measured orchestrator behavior cited in the commit message. Good to merge.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 1c151f5.

Here is the review of the pull request diff.

Findings

Severity: P2
File: scripts/validate-agents-state-approval.py
Anchor: print(f"::error file={path.relative_to(ROOT)}::{rel}: {reason}", file=sys.stderr)
Concrete failure scenario: GitHub Actions workflow commands (like ::error) require specific characters (newline %0A, carriage return %0D, percent sign %25) to be URL-encoded. The reason variable can contain unescaped newlines, most notably when it includes a yaml.YAMLError string representation which spans multiple lines to show the error snippet and location. Because the newlines are printed as-is, GitHub Actions truncates the annotation at the first line. The developer sees a vague "is not parseable YAML" error on their PR diff without the crucial line number or context. Furthermore, if an attacker commits a directory path containing a newline and a workflow command (e.g. test\n::warning::malicious), it will be interpolated directly into the output, allowing for log injection or execution of arbitrary workflow commands.
Proposed fix: URL-encode %, \r, and \n in the user-controlled strings before interpolating them into the ::error command. For example:

reason_str = str(reason).replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
rel_str = str(rel).replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
print(f"::error file={path.relative_to(ROOT)}::{rel_str}: {reason_str}", file=sys.stderr)

Severity: P3
File: scripts/validate-agents-state-approval.py
Anchor: ("root_is_false", "false\n", True),
Concrete failure scenario: The script explicitly handles genuinely empty documents by bypassing the evaluation and returning (if data is None: continue), but there is no self-test case validating this behavior. If a future refactoring accidentally removes or modifies this check, empty documents will fall through to if not isinstance(data, dict): and be improperly flagged as unrunnable. Because there is no test visible in this diff covering empty documents, this regression would go unnoticed by the self-test.
Proposed fix: Add a test case for an empty document to SELFTEST_CASES that expects False (e.g., ("empty_document", "", False)).

… branch

Two agy findings on 1c151f5, both real.

P2 -- the ::error annotation interpolated unescaped values

A GitHub Actions workflow command is terminated by a newline, so an unescaped
one truncates the message there. That lands hardest on the case that most needs
detail: a yaml.YAMLError renders across several lines carrying the line and
column, and all of it after the first line was being dropped.

The same interpolation is a log-injection vector. A proposal directory whose
name contains a newline followed by `::warning::...` would have that command
executed by the runner, from a path this validator itself prints.

Percent, CR and LF are now escaped -- percent first, or it would double-escape
the other two.

P3 -- the empty-document branch had no test

`if data is None: continue` was added to keep a genuinely empty file out of the
falsey-root finding. Nothing asserted it, so a refactor could drop it and start
flagging empty files as unrunnable with nothing to catch the regression.

Self-test goes to 15 cases (7 rejected, 8 accepted) and the new one is
mutation-proven: deleting the branch fails with "wrongly flagged
empty_document".

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@mashkovd

Copy link
Copy Markdown
Member Author

Both agy findings fixed in 86f07442. Both were real, and the P2 was the more serious of the two.

P2 — unescaped values in the ::error annotation

Correct on both counts.

The truncation lands hardest on the case that most needs detail: a yaml.YAMLError renders across several lines carrying the line and column, and a workflow command is terminated by a newline — so everything after the first line was being dropped. The annotation said "is not parseable YAML" and threw away the part that says where.

The injection is the sharper half. This validator prints a path it discovered by globbing, so a proposal directory whose name contains a newline followed by ::warning::... would have that command executed by the runner. That is a directory name anyone able to open a PR against this repo can create.

%, \r and \n are now escaped — percent first, or it would double-escape the other two:

"line1\nline2\rline3"      -> 'line1%0Aline2%0Dline3'
"evil\n::warning::pwned"   -> 'evil%0A::warning::pwned'
"100%25already"            -> '100%2525already'

Applied to all three interpolated values, not just reason: the path and the relative path are equally attacker-shaped.

P3 — the empty-document branch had no test

Correct, and it is a gap in my own discipline rather than an edge case. if data is None: continue was added one commit earlier specifically in response to a Codex finding about falsey roots — and then shipped with nothing asserting it. A refactor dropping it would start flagging every empty file as unrunnable, and --selftest would have stayed green.

Added, and mutation-proven like the rest: deleting the branch fails with wrongly flagged empty_document.

Self-test is now 15 cases — 7 rejected, 8 accepted.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 44s —— View job


Review of PR #1210 (commit 86f0744) — complete

  • Diff base f1c8d57...86f0744 (.github/workflows/validate-manifests.yml, scripts/validate-agents-state-approval.py)
  • Verify _wc_escape fix for the agy P2 (workflow-command injection / newline truncation)
  • Verify the added empty_document self-test case (agy P3)
  • Check for any other correctness/security issues introduced by this commit
  • Post findings / verdict

Verdict: APPROVED (1 P3, no P1/P2)

Reviewed git diff f1c8d570...86f0744 against the pinned commit.

  • _wc_escape (scripts/validate-agents-state-approval.py:134) correctly neutralizes both agy findings on 1c151f56: the escape order is percent → \r → \n (percent must come first or it would double-escape the others), which stops both the newline-truncation of ::error annotations and the log-injection vector (a directory name containing \n::warning::...).
  • The new empty_document self-test case pins the data is None branch in check_dir() so a future refactor that drops it would be caught, per agy's P3.
  • Traced check_dir → unrunnable → requires_human_approval/has_named_approver end to end: the shape-before-approver ordering and the or {} removal from the previous round are both intact, and the PARITY block's four measured cases still hold against the escaping change (which only touches error-message formatting, not the predicate).

P3 (posted inline, non-blocking): file= in the ::error annotation is a workflow-command property, not free-form data. GitHub's own escaping (actions/toolkit's escapeProperty) additionally replaces : → %3A and , → %2C for properties, since the runner splits the property list on commas. _wc_escape only does the data-level escaping (%/\r/\n), so a proposal path containing a literal comma or colon would still render as a malformed file= property — not a security issue (newlines are handled), just a cosmetic annotation-rendering gap.

for path, reason in findings:
rel = path.relative_to(agents_state)
print(
f"::error file={_wc_escape(path.relative_to(ROOT))}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: _wc_escape fixes the injection-critical part (%, \r, \n), but the file= argument is an annotation property, not free-form data. GitHub's own workflow-command escaping distinguishes the two: data only needs %/\r/\n escaped, but properties (like file=) additionally need : → %3A and , → %2C, since the runner splits properties on comma and parses key:value pairs. A proposal directory name containing a comma or colon would still render as a malformed/truncated file= property (not a security issue since newlines are already handled, just a correctness gap in the annotation). Consider a separate _wc_escape_property used only for the file= value, mirroring escapeProperty vs escapeData in actions/toolkit.

@claude claude 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.

No P1/P2 findings (1 P3). This commit correctly fixes the two prior agy findings: _wc_escape (percent-then-CR-then-LF order) neutralizes the workflow-command injection/truncation on the ::error annotation, and the empty_document self-test case pins the data-is-None branch. Verified check_dir/unrunnable/requires_human_approval/has_named_approver ordering (shape-before-approver) and the PARITY cases still hold. One P3 posted inline: file= is an annotation property and technically also needs ':' and ',' escaped per actions/toolkit's escapeProperty, not just escapeData's %/\r/\n — non-blocking.

@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: 86f0744246

ℹ️ 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 +80 to +81
if data.get("status") != "accepted":
return None

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 proposed records before approving them

When a hand-written proposed record has a non-mapping control value, this return lets it pass validation even though the normal approval workflow will turn it into the exact deadlocked state this gate is meant to prevent. In cwft-mctl-agents-approve.yaml, the approve step only changes status and appends an approval (lines 239-259), then commits directly to main (lines 451-455); because the implementer rejects malformed control even with a named approver, validation only turns red after the unrunnable commit has already landed. Reject malformed control blocks while they are still proposed, or validate them before the approve workflow pushes.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 86f0744.

No significant issues found.

…escape the file= property

Codex P2 -- the gate went red one commit too late

A `proposed` record with a non-mapping `control` passed, because the check sat
behind `status != accepted`. That is not harmless: the approve workflow only
flips `status` and appends an approval, then commits straight to main. So
approving such a record MANUFACTURES the deadlocked accepted state through an
entirely supported path, and this gate would only fail on the NEXT run --
after the unrunnable record has already landed.

The shape check now runs at any status. The implementer fails closed on a
malformed control block regardless of any approver, so such a proposal can
never run whatever its current status says.

claude P3 -- file= is a property, not data

GitHub's runner splits annotation properties on commas and parses each as
key:value, so a property needs `:` and `,` escaped on top of what data needs --
the same split actions/toolkit makes between escapeProperty and escapeData. A
proposal directory carrying either character rendered a malformed or truncated
`file=`. `_wc_escape_property` wraps `_wc_escape` for that one argument; the
message text keeps data escaping, where a colon is ordinary.

Self-test goes to 17 cases (8 rejected, 9 accepted). Mutation-proven: moving
the shape check back behind the accepted guard fails with "did not flag
proposed_control_not_mapping". `proposed` with `control:` null still passes,
since null is indistinguishable from absent to the authoritative parser too.

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 50s —— View job


Review of PR #1210 (commit ed07e64) — complete

  • Fetch latest and diff f1c8d570...ed07e64
  • Review new commits since last review (1c151f56, 86f07442, ed07e64e)
  • Post findings / verdict

Verdict: APPROVED (1 P3, no P1/P2)

Focused on the final commit 86f07442...ed07e64e, which addresses the two outstanding review threads:

  • Codex P2 ("validate proposed records before approving them") — fixed. unrunnable() now checks the shape of control before the status check, at any status, not just accepted. A proposed record with a non-mapping control is now flagged before the approve workflow can flip it to accepted and commit the deadlocked state to main. Confirmed via the new proposed_control_not_mapping (flagged) / proposed_control_null (not flagged) self-test cases.
  • claude P3 ("file= needs property escaping, not just data escaping") — fixed. New _wc_escape_property() layers :/, escaping on top of _wc_escape()'s %/\r/\n, applied only to the file= value — correctly mirroring actions/toolkit's escapeProperty vs escapeData split. The message portion (rel, reason) correctly keeps using plain _wc_escape, since those aren't properties.

Traced check_dir → unrunnable → requires_human_approval/has_named_approver end to end again with the reordered shape check; no regression on the previously-fixed ordering (shape-before-approver) or the or {} removal.

P3 (posted inline, non-blocking): --selftest exercises check_dir()/the predicate thoroughly but never calls report(), so _wc_escape/_wc_escape_property themselves (the actual fixes for the two prior findings) have no automated regression test — only the manual traces in the PR description. A small direct unit assertion on those two functions would pin the escaping behavior independent of the shape predicate.

for path, reason in findings:
rel = path.relative_to(agents_state)
print(
f"::error file={_wc_escape_property(path.relative_to(ROOT))}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: --selftest exercises check_dir()/the predicate logic thoroughly but never calls report(), so the new _wc_escape/_wc_escape_property behavior (the fixes for the agy P2 log-injection/truncation finding and the claude P3 property-escaping finding on this PR) has no automated regression test — only the PR-description manual traces. A future refactor that drops _wc_escape_property's :/, handling, or reintroduces unescaped newlines, would ship silently green. Consider a small non-selftest unit check (e.g. asserting _wc_escape("a\n::warning::x") == "a%0A::warning::x" and _wc_escape_property("a:b,c") == "a%3Ab%2Cc") so the escaping itself is pinned, not just the shape predicate.

@claude claude 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.

No P1/P2 findings (1 P3). Verified the two fixes in this commit: the control-shape check now runs before the status check (so a malformed control on a proposed record is caught before the approve workflow can turn it into a deadlocked accepted state, per Codex's finding), and _wc_escape_property correctly adds :/, escaping on top of _wc_escape's %/\r/\n for the file= annotation property, matching actions/toolkit's escapeProperty/escapeData split. Traced the self-test's new parity cases (accepted_control_not_mapping_with_approver, proposed_control_not_mapping, proposed_control_null) by hand against the updated `unrunnable()" and they're consistent. Good to merge.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit ed07e64.

No significant issues found.

@mashkovd
mashkovd merged commit 8f9a4ab into main Sep 12, 2026
21 checks passed
@mashkovd
mashkovd deleted the feat/validate-agents-state-approval branch September 12, 2026 10:51
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.

No gate stops an unrunnable proposal being committed straight into agents-state

1 participant