Skip to content

Re-check the standing lease when a proposal is committed - #105

Merged
b-macker merged 1 commit into
masterfrom
claude/naab-inadmissible-action-prevention-4cmn1m
Jul 29, 2026
Merged

Re-check the standing lease when a proposal is committed#105
b-macker merged 1 commit into
masterfrom
claude/naab-inadmissible-action-prevention-4cmn1m

Conversation

@b-macker

@b-macker b-macker commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

agent.propose() gates the standing lease when candidates are generated. agent.commit() did not gate it at all. Since the deliberation gap between the two is the entire point of propose/commit, a wall-clock lease can lapse inside that gap and the commit lands anyway.

Reproduced against the stub with standing_lease_seconds: 2 — propose, wait 4s, commit:

PROPOSED turns=0
COMMIT_SUCCEEDED turns=1        <- turn advanced, history appended
SEND_DENIED (lease enforced on send path)

Same handle, same instant, same expired lease: send refused, commit went through. This isn't an agent attack — it's the intended adjudication workflow, and it's a recurrence of the bug leaseExpiredLocked() was extracted to fix one layer up (propose once checked only the turn-based half). Authority is time-varying; a proposal is not, so the check belongs on the commit half.

Changes

  • src/stdlib/agent_impl.cppagentCommit() calls the existing leaseExpiredLocked() inside its existing s_agent_mutex block (no new lock, no lock-ordering change, no I/O), evaluating under the lock and enforcing outside it, matching agentPropose()'s shape. Refusal mirrors the two existing shapes so callers keep matching on them: the step-up substring when step_up_enabled (renewable — agent.send() resets both lease halves on a passed challenge), the hard "lease expired" wording when there's no re-auth path.
  • examples/living-script_extended/src/living-script.naab — the one real consumer, and the exact affected config (standing_lease_seconds: 600 + propose_candidates_max: 3). It previously wrapped only propose in its step-up remedy, so a commit-time expiry would have been swallowed by the outer handler and surfaced as "propose/commit produced nothing" rather than "lease expired". Now pairs a pre-emptive lease_remaining_seconds renewal with a one-shot retry. The retry redoes propose → select → commit as a unit, because the re-authorizing send invalidates outstanding proposals by design — retrying just the commit cannot work.
  • tests/governance_v4/test_propose_commit.sh — new Group G.
  • CLAUDE.md — the propose/commit section documented the old behaviour ("commit has no step-up/lease gate of its own") and would have become wrong.

Scope

Only the wall-clock half can fire. Turns cannot advance between propose and commit, so a turn-based lease that passed propose is still valid at commit by construction. G-02 holds that claim honest instead of assuming it.

Deliberately not included

Each traced to a concrete failure rather than left out by omission:

  • reloadIfChanged() — an accepted reload runs onAgentConfigChanged(), which re-baselines mandate_keywords and clears the alignment histories; the candidate was generated at propose time under the old mandate, so S11/S20 would score it against an instruction it never received. It also swaps rules_ptr_ atomically, dangling config.
  • hard_stopped — a run-level budget kill; every existing call site guards a path about to spend. commit makes no API call and the candidate was paid for at propose time.
  • governance_epoch_ equality stamp — it increments on pulse verdict transitions as well as config reloads, and the pulse sawtooths, so proposals would be voided during normal degraded operation.

reload_count_ stamping remains open — it's the honest remainder of "current governance conditions", but it adds a second refusal mode and deserves its own change.

Test Plan

  • test_propose_commit.sh — 18/18, Groups A–F unchanged, G-01/G-02/G-03 new
  • Verified the gate is load-bearing, not vacuous: temporarily stubbing lease_expired = false makes G-01 and G-03 fail while G-02 still passes (G-02 covers the path the gate must not touch)
  • test_split_commit.sh 11/11 — commit-path regression
  • tests/security/test_error_msg_leaks.sh — 874 checks, 0 failures (new error text)
  • bash run-all-tests.sh441 tests, 0 unexpected failures
  • living-script: parse clean; check diagnostics unchanged at 35 before and after (the two in the edited region are the pre-existing "analyzer doesn't know stdlib modules" class, orchestra/codegen)
  • living-script end-to-end against the live Gemini API — both remedy layers driven deliberately, each forced rather than left to whichever happened to fire:
    • Pre-emptive (lease_floor_seconds = 999999): PROPOSE_SELECT|lease_preemptive_reauth|remaining=494PROPOSE_SELECT|committed=true
    • Retry (lease_floor_seconds = 0, standing_lease_seconds: 30): PROPOSE_SELECT|step_up_reauth|attempt=1PROPOSE_SELECT|committed=true
    • Both temporary config edits reverted; branch carries no leftover changes.

Note on what the live run does and does not establish: it proves the remedy wiring end to end (refusal → re-authorize → re-propose → commit). The commit-boundary gate itself is proven by G-01 against the stub, which is deterministic. In a live run the 30s lease is already expired by the time the phase executes, so the refusal is raised at propose and the unified catch handles it identically — timing an expiry to land precisely inside the propose→commit window is not reliably reproducible, which is exactly why that case belongs in the stub test.

Related Issues

None.

agent.propose() gates the standing lease when candidates are generated.
agent.commit() did not gate it at all. Since the deliberation gap between
the two is the entire point of propose/commit, a wall-clock lease can lapse
inside that gap and the commit lands anyway:

    PROPOSED turns=0
    COMMIT_SUCCEEDED turns=1        <- turn advanced, history appended
    SEND_DENIED (lease enforced on send path)

Same handle, same instant, same expired lease: send refused, commit went
through. This is not an agent attack, it is the intended adjudication
workflow, and it is a recurrence of the bug leaseExpiredLocked() was
extracted to fix one layer up. Authority is time-varying; a proposal is not,
so the check belongs on the commit half.

Only the wall-clock half can fire here. Turns cannot advance between propose
and commit, so a turn-based lease that passed propose is still valid at
commit by construction — G-02 holds that claim honest rather than assuming
it, and would catch a gate that had become over-broad.

The refusal mirrors the two existing shapes so callers keep matching on it:
the "step-up" substring when step_up_enabled, since agent.send() renews both
lease halves on a passed challenge; the hard "lease expired" wording when
there is no re-auth path to point at.

Because the re-authorizing send invalidates outstanding proposals, a caller
cannot retry the commit — it has to redo propose, select and commit as a
unit. living-script previously wrapped only propose in its step-up remedy,
so a commit-time expiry would have been swallowed by the outer handler and
shown up as "propose/commit produced nothing" rather than "lease expired".
It now pairs a pre-emptive lease_remaining_seconds renewal with a one-shot
retry of the whole cycle.

Deliberately not added, each traced: hard_stopped and recordAutonomousAction
guard spend and commit makes no API call; reloadIfChanged() would re-baseline
mandate_keywords via onAgentConfigChanged() and score an already-generated
candidate against a mandate it never received, besides dangling config
through the atomic rules_ptr_ swap.

Full suite: 441 tests, 0 unexpected failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELUfjXZvx8kzXo1UJjrAhC
@github-actions

Copy link
Copy Markdown

NAAb Governance Report

Metric Count
Files checked 16
Passed 16
Failed 0

All governance checks passed!

Generated by NAAb Governance Engine v4.0

@b-macker
b-macker marked this pull request as ready for review July 29, 2026 23:31
@b-macker
b-macker merged commit dfd443e into master Jul 29, 2026
23 checks passed
@b-macker
b-macker deleted the claude/naab-inadmissible-action-prevention-4cmn1m branch July 29, 2026 23:32
b-macker added a commit that referenced this pull request Jul 30, 2026
#105 established that every check whose truth can change with the passage of
time belongs on the commit half, because authority is time-varying and a
proposal is not. Two holes in that invariant remained, both on this path.

Config changes were not re-checked across the propose->commit gap. commit()
deliberately does not call reloadIfChanged() — an accepted reload runs
onAgentConfigChanged(), which re-baselines mandate_keywords and would score an
already-generated candidate against a mandate it never received, besides
dangling `config` through the atomic rules_ptr_ swap. That decision was right,
but its consequence was left open: a candidate produced under one configuration
could still be committed under another. Proposals now carry the accepted-config
generation they were evaluated under, and commit refuses when it has moved —
declining to commit under conditions the candidate never saw, rather than
re-scoring it under them.

reload_count_ and not governance_epoch_: the epoch also moves on pulse verdict
transitions, and the pulse sawtooths, so proposals would be voided during
normal degraded operation.

That stamp was not additive. reload_count_ was a plain int while
governance_epoch_ beside it is atomic; it is written under reload_mutex_ but
getReloadCount() read it unsynchronised, so reading it from agent worker
threads during batch/fan_out would have been a data race. It is now
std::atomic<int>. The accessor had no callers, so the change is contained to
four sites.

Second hole: agentPropose's lease test sat inside `if (cb.step_up_enabled)`,
and step_up_enabled defaults to false — so by default propose did not consult
the lease at all, while send() hard-throws on an expired lease and commit()
checks it unconditionally. Propose was the only entry point where expired
authority passed silently. The lease test is now unconditional; the level test
stays inside the guard because required_level derives from step_up_at_level and
means nothing when step-up is off.

No config in the repo combines propose with a lease and step-up off — the two
propose configs with step-up off have no lease at all, so the hoisted check is
a no-op for them. The change reaches only the configuration nobody was using.

Groups H and I cover both. Two mechanics cost real time to find and are
recorded in the test: reloadIfChanged() compares mtime at second granularity,
so a rewrite in the same second is invisible; and subprocess env is scrubbed,
so a re-sign must be passed --signing-key explicitly.

Vacuity-checked: disabling the stamp fails H-01 while H-02 still passes,
restoring the old nesting fails I-01 while I-02 and I-03 still pass. asan and
ubsan both green in CI, which is what would have caught the atomic change being
wrong.

Full suite: 441 tests, 0 unexpected failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants