diff --git a/CLAUDE.md b/CLAUDE.md index 3aad2942..d29696e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,7 @@ include/naab/ All headers - **Separation of duties**: per-agent `network_allowed` (bool, same pattern as `shell_allowed`) and `allowed_actions` matrix (`["SHELL_EXEC", "NET_CONNECT", "FS_READ", "FS_WRITE", "AGENT_SEND", "TOOL_EXEC"]`). Enforced in `checkNetworkAllowed()`, `checkShellAllowed()`, `checkFilesystemAllowed()`, and `agentSend()`. `TOOL_EXEC` controls tool execution in agent tool loops. Ratchet enforcement prevents mid-run loosening of action matrix. - **Output admissibility**: Post-CDD gate on response coherence — symmetric to `checkAdmission()` (pre-send). Evaluates after CDD scoring, before response dict construction. Three actions: `block` (enforce() throws, response not returned — HARD/SOFT = GovernanceHardError, DETECT = catchable), `quarantine` (response returned with `admissibility.admissible = false`), `attest` (quarantine + Ed25519 signed attestation in response + telemetry). PulseVerdict IMPAIRED forces inadmissible regardless of score. Config: `circuit_breaker.output_admissibility.enabled`, `threshold` (default 0.70), `action` (default "quarantine"), `level` (for "block" only, clamped to minimum DETECT), `inadmissible_history` ("commit" default / "exclude" — whether quarantined/attested responses enter handle history), `gate_tool_calls` (bool, default false — coherence/pulse gate before each tool execution in the tool loop), `max_quarantine_streak` (default 5, 0 = disabled — consecutive quarantined responses before GovernanceHardError terminates the agent; prevents the quarantine+commit degradation loop), `require_corroboration` (default 0 = disabled/historical; N = a quarantined turn only ADVANCES the streak when N DISTINCT penalising signals fired that turn). Coherence is a weighted sum, so one noisy signal firing repeatedly accumulates to a kill alone — on replayed traces `semantic_stability` firing on a compliant agent's varied phrasing killed the run at turn 8, and corroboration=2 removed that false kill while every genuine failure mode still died on the same turn. Counted from `DriftState.last_turn_penalties` (one slot per signal: distinct by construction, absorbed firings excluded, detection-only `coherence_velocity` excluded free since it never penalises). Do NOT use `signals_fired_this_turn` — `repeated_failures` and `intent_contradictions` increment it inside per-item loops, so one signal could corroborate itself. Gates the streak ADVANCE only: admissibility, history disposition, attestation and `OUTPUT_INADMISSIBLE` are unchanged, and a declined advance emits `QUARANTINE_UNCORROBORATED`. Applied identically at both enforcement sites (`agentSend` and `agentCommit`). Ratchet: enabling it or raising N yields fewer kills = loosening. Ratchet-enforced (enabled→disabled, threshold down, action rank down, exclude→commit, gate_tool_calls true→false, streak limit removed/raised = violations). Telemetry: `OUTPUT_ADMISSIBILITY_EVAL` (pass/fail), `OUTPUT_INADMISSIBLE` (fail only, includes `history_committed`). Dashboard: `OA Gate:` line. - **Split commit** (see `docs/transition-admissibility.md`): `agent.send()` separates accounting commits (tracker turns/tokens + `recordAutonomousAction()` exposure — committed BEFORE the post-receive gates, so blocked attempts still count toward the next admission projection) from conversation-state commits (history append happens AFTER CDD + OA gate — blocked turns, including catchable DETECT blocks, never enter handle history). - - **Propose/commit** (selective adjudication): `agent.propose(handle, msg [, n])` generates up to `propose_candidates_max` (per-agent config, 0 = disabled, increase = ratchet violation) candidates with NO state commit — no history append, no turn increment, no CDD mutation, no tool execution (tool defs never sent). Candidate 0 uses the configured temperature; candidates 1+ step it up (+0.15 each, capped at 1.5) for sampling diversity — a local-copy change only, never committed to config or history. Each candidate carries an `admissibility` section (read-only score vs current DriftState snapshot) and an anti-forge `__proposal` HMAC nonce; authoritative content lives server-side in `s_pending_proposals`. `agent.commit(handle, proposal)` runs the post-receive pipeline exactly once (BSD AGENT_RESPONSE, CDD, full enforce-capable OA gate, split commit of history + turn). Proposals are single-use and invalidated by any subsequent send/propose/commit on the handle. Lease-expired state refuses propose (fail-closed — re-authorize via `agent.send()`, which renews the lease on a passed challenge). The gate is the **standing lease, not the engine-global governance level**: level is scrutiny, not authorization, and nothing the handle does lowers it (a passed challenge renews the lease and recovers coherence; de-escalation needs `deescalate_sustained` calm pressure samples), so gating on level made the error message's own remedy unsatisfiable — live run 12 refused propose for a handle holding 19 turns of lease after 47 passed challenges. Lease expiry covers BOTH `standing_lease_turns` and `standing_lease_seconds` via the shared `leaseExpiredLocked()` helper (propose previously checked only the turn-based half; the level clause was masking that). Agents with **no lease configured** keep the level test — there is no authorization state to consult, so loosening them would be a pure weakening; configure a lease if propose must work under elevated scrutiny. CRITICAL denies regardless of lease. Note `agent.commit()` has no step-up/lease gate of its own — propose's gate is the only authorization check on the path (commit still runs CDD + the full enforce-capable OA gate). Test: `test_propose_commit.sh` Group F. `orchestra.select_admissible(candidates [, spec])` is the pure ranking helper (spec reuses enforce_convergence pattern/required_fields). Telemetry: `AGENT_PROPOSE`, `AGENT_PROPOSAL_COMMIT`. + - **Propose/commit** (selective adjudication): `agent.propose(handle, msg [, n])` generates up to `propose_candidates_max` (per-agent config, 0 = disabled, increase = ratchet violation) candidates with NO state commit — no history append, no turn increment, no CDD mutation, no tool execution (tool defs never sent). Candidate 0 uses the configured temperature; candidates 1+ step it up (+0.15 each, capped at 1.5) for sampling diversity — a local-copy change only, never committed to config or history. Each candidate carries an `admissibility` section (read-only score vs current DriftState snapshot) and an anti-forge `__proposal` HMAC nonce; authoritative content lives server-side in `s_pending_proposals`. `agent.commit(handle, proposal)` runs the post-receive pipeline exactly once (BSD AGENT_RESPONSE, CDD, full enforce-capable OA gate, split commit of history + turn). Proposals are single-use and invalidated by any subsequent send/propose/commit on the handle. Lease-expired state refuses propose (fail-closed — re-authorize via `agent.send()`, which renews the lease on a passed challenge). The gate is the **standing lease, not the engine-global governance level**: level is scrutiny, not authorization, and nothing the handle does lowers it (a passed challenge renews the lease and recovers coherence; de-escalation needs `deescalate_sustained` calm pressure samples), so gating on level made the error message's own remedy unsatisfiable — live run 12 refused propose for a handle holding 19 turns of lease after 47 passed challenges. Lease expiry covers BOTH `standing_lease_turns` and `standing_lease_seconds` via the shared `leaseExpiredLocked()` helper (propose previously checked only the turn-based half; the level clause was masking that). Agents with **no lease configured** keep the level test — there is no authorization state to consult, so loosening them would be a pure weakening; configure a lease if propose must work under elevated scrutiny. CRITICAL denies regardless of lease. `agent.commit()` re-checks the lease via the same `leaseExpiredLocked()` helper, in its existing `s_agent_mutex` block: authority is time-varying and a proposal is not, so the deliberation gap propose/commit exists to create is exactly where a wall-clock lease can lapse. Only the wall-clock half can fire there — turns cannot advance between propose and commit, so a turn-based lease that passed propose is still valid at commit by construction. The refusal mirrors the two existing shapes: a message carrying the `step-up` substring when `step_up_enabled` (renewable via `agent.send()`), the hard "lease expired" wording when not. Because the re-authorizing send *invalidates outstanding proposals*, a caller must redo propose→select→commit as a unit rather than retry the commit — `examples/living-script_extended` shows the pattern, pairing a pre-emptive `lease_remaining_seconds` check with a one-shot retry. Commit still omits `hard_stopped` and `recordAutonomousAction` deliberately: both guard *spend*, and commit makes no API call (the candidate was paid for at propose time). Commit also deliberately does NOT call `reloadIfChanged()` — an accepted reload runs `onAgentConfigChanged()`, which would re-baseline `mandate_keywords` and score an already-generated candidate (S11/S20) against a mandate it never received, and the atomic `rules_ptr_` swap would dangle `config`. Tests: `test_propose_commit.sh` Groups F (propose) and G (commit). `orchestra.select_admissible(candidates [, spec])` is the pure ranking helper (spec reuses enforce_convergence pattern/required_fields). Telemetry: `AGENT_PROPOSE`, `AGENT_PROPOSAL_COMMIT`. - **Governance level de-escalation hysteresis**: escalation is immediate once sustained-pressure thresholds are met, but stepping DOWN requires `circuit_breaker.deescalate_sustained` (default 2) consecutive turns where the computed target level sits below the current level, and moves one level at a time (engine-global `deescalate_calm_turns_` counter; pulse floors still apply). Calm turns are counted only from the handle whose pressure raised/held the level (`deescalate_pressure_handle_`) — a calm sibling agent's turns neither advance nor reset the counter, so interleaved multi-agent traffic can't drain scrutiny a degraded agent earned. Previously a single calm composite sample dropped straight to NORMAL — scrutiny vanished exactly when a decaying agent briefly looked calm. Ratchet: lowering `deescalate_sustained` mid-run is a violation. - **Campaign findings**: `docs/governance-campaign-findings.md` maps each governance defect found in living-script runs 7-23 to its mechanism, its regression test, and whether the fix has ever fired against a live API (confirmed / inert-verified / stub-only / n/a). Also records observations deliberately NOT changed (S9's frozen baseline, S17's warm-up baseline and adaptive-baseline gating, CDD's blindness to erosion from the response stream) and four proposals withdrawn on evidence — read it before "fixing" any of those. - **Governance Pulse** (`src/runtime/governance_engine.cpp`): PulseVerdict (HEALTHY/DEGRADED/IMPAIRED) with hysteresis, two-phase mutex, stepped recovery, BSD emission on transitions, `PULSE_TRANSITION` telemetry on every verdict change (from/to verdict, degradation_reasons, streak counters, epoch) — the BSD events alone surface only when a pattern matches them, so a degrade-then-recover between two `governance.health()` samples was previously visible only as an unexplained two-step jump in `governance_epoch`. Dashboard line. `governance.health()` stdlib returns `{verdict, coherence, governance_epoch, mode, zone, active, consecutive_passes, observation_count, integrity_verified, ...}` — it does **not** expose `governance_level`; read the level from `GOVERNANCE_LEVEL_CHANGE` telemetry (`from_level`/`to_level`) or `CDD_TURN`'s `level` field instead. Config: `governance_health` section. Six degradation signals, each named in `pulse.degradation_reasons` (exposed by `governance.health()`, in the `GOVERNANCE_HEALTH_QUERY` telemetry event, on the dashboard's `degraded by:` line, and in the PULSE_DEGRADED/PULSE_IMPAIRED BSD event text): `bsd_disconnected`, `cdd_disconnected`, `low_entropy`, `telemetry_stalled`, `semantic_inert`, `uniform_passes`. The reason **latches** — reaching DEGRADED resets the clean-turn streak, so the next evaluation finds nothing firing while the verdict is still DEGRADED; the field keeps the cause of the current verdict and clears on recovery to HEALTHY. The verdict sawtooths (DEGRADED resets the streak → recovery two turns later → climbs again), so a single terminal sample says nothing about whether a tripwire ever tripped — sample per turn or read the transitions. `pulse.consecutive_passes` counts **consecutive agent turns with no enforcement**, ticked once per pulse evaluation in `computePulseVerdict()` and reset by `enforce()` (via `blocked_since_last_pulse`) and on epoch boundaries. It is deliberately NOT incremented in `recordPass()`: every `recordPass` site is a static-source, capability, plugin or runtime-pin check — nothing on the agent-behaviour path records passes at all — so counting them there measured how much generated source had been scanned. `agent_governance_active_` did not contain that (it is a one-way latch set at the first `agent.create()`), so post-agent `codegen.run()` and polyglot blocks fed ~5 uniform passes each into the counter and any codegen-heavy run saturated `consecutive_passes_suspicion` on volume alone. Test: `tests/governance_v4/test_pulse_uniformity.sh`. Note the pulse is evaluated only from `checkContextDrift()` — with CDD disabled (or BSD disabled, which starves CDD of AGENT_RESPONSE events) it never runs. diff --git a/examples/living-script_extended/src/living-script.naab b/examples/living-script_extended/src/living-script.naab index 0a997a66..f33bb9a7 100644 --- a/examples/living-script_extended/src/living-script.naab +++ b/examples/living-script_extended/src/living-script.naab @@ -1294,67 +1294,98 @@ main { if handles.get("developer") != null && len(pipeline_code) > 20 { check_coherence(handles["developer"], "developer") let propose_msg = "Add a get_stage_names() method to the Pipeline class that returns a list of all stage names added via add_stage(). Add type hint and docstring.\n\n" + invariants + "\n\nCurrent pipeline.py:\n```python\n" + pipeline_code + "\n```\nOutput the COMPLETE file with get_stage_names added. ```python fences. Code only." - try { - let proposals = null + // The standing lease is enforced at BOTH ends of this cycle: propose + // gates it when the candidates are generated, and commit gates it + // again when the transition becomes real. A wall-clock lease can + // therefore expire in the deliberation gap between them. Two layers + // handle it — a pre-emptive renewal so the common case never sees a + // refusal, and a retry so a mid-cycle expiry is still recoverable. + // + // The retry has to redo the WHOLE cycle, not just the failed step: + // the re-authorizing send invalidates any outstanding proposal by + // design, so a candidate generated before the send cannot be + // committed after it. + let lease_floor_seconds = 120 // ~3 propose calls plus a commit + + // Layer (a): renew before starting if headroom is thin. + let dev_state = agent.environment(handles["developer"]).get("state") ?? {} + let lease_left = dev_state.get("lease_remaining_seconds") ?? -1 + if lease_left >= 0 && lease_left < lease_floor_seconds { + print("PROPOSE_SELECT|lease_preemptive_reauth|remaining=" + string(lease_left)) + let pre_reauth = agent.send(handles["developer"], "Confirm you are ready to proceed. Restate your current objective in one sentence.") + tracking["total_sends"] = tracking["total_sends"] + 1 + track("developer", pre_reauth) + post_send_check(handles["developer"], pre_reauth, "developer") + } + + // Layer (b): propose -> select -> commit as one retryable unit. + let attempt = 0 + let cycle_done = false + while attempt < 2 && cycle_done == false { + attempt = attempt + 1 try { - proposals = agent.propose(handles["developer"], propose_msg, 3) + let proposals = agent.propose(handles["developer"], propose_msg, 3) + let candidates = proposals.get("candidates") ?? [] + propose_candidates = candidates.length() + print("PROPOSE_SELECT|candidates=" + string(propose_candidates)) + + // Each candidate carries its own read-only admissibility score. + // Printing them is what makes the selection auditable: without it + // there is no way to tell ranking-by-score from taking candidate 0. + let cai = 0 + while cai < candidates.length() { + let cand_adm = candidates[cai].get("admissibility") ?? {} + print("PROPOSE_CAND|" + string(cai) + "|admissible=" + string(cand_adm.get("admissible") ?? false) + "|score=" + string(cand_adm.get("score") ?? -1) + "|threshold=" + string(cand_adm.get("threshold") ?? -1) + "|reason=" + string(cand_adm.get("reason") ?? "") + "|len=" + string(len(string(candidates[cai].get("content") ?? "")))) + cai = cai + 1 + } + + if propose_candidates > 0 { + let selected = orchestra.select_admissible(proposals) + print("PROPOSE_SELECT|admissible_count=" + string(selected.get("admissible_count") ?? -1) + "|selected_index=" + string(selected.get("selected_index") ?? -1) + "|total=" + string(selected.get("total") ?? -1)) + // select_admissible returns {selected, selected_index, + // admissible_count, total}; "selected" is a candidate dict or + // null when nothing cleared the threshold. + let best = selected.get("selected") + if best != null { + let committed = agent.commit(handles["developer"], best) + propose_committed = true + track("developer", committed) + post_send_check(handles["developer"], committed, "developer") + let committed_code = agent.extract_code(committed.get("content") ?? "", "python") + if len(committed_code) > 20 { + pipeline_code = committed_code + } + print("PROPOSE_SELECT|committed=true") + } + } + cycle_done = true } catch (e) { - if string(e).contains("step-up") { - print("PROPOSE_SELECT|step_up_reauth") + // Re-authorize and redo the cycle once. A second consecutive + // step-up refusal is reported rather than retried again — + // looping on it would burn the challenge-failure budget. + if string(e).contains("step-up") && attempt < 2 { + print("PROPOSE_SELECT|step_up_reauth|attempt=" + string(attempt)) let reauth = agent.send(handles["developer"], "Confirm you are ready to proceed. Restate your current objective in one sentence.") tracking["total_sends"] = tracking["total_sends"] + 1 track("developer", reauth) post_send_check(handles["developer"], reauth, "developer") - proposals = agent.propose(handles["developer"], propose_msg, 3) } else { - throw e - } - } - let candidates = proposals.get("candidates") ?? [] - propose_candidates = candidates.length() - print("PROPOSE_SELECT|candidates=" + string(propose_candidates)) - - // Each candidate carries its own read-only admissibility score. - // Printing them is what makes the selection auditable: without it - // there is no way to tell ranking-by-score from taking candidate 0. - let cai = 0 - while cai < candidates.length() { - let cand_adm = candidates[cai].get("admissibility") ?? {} - print("PROPOSE_CAND|" + string(cai) + "|admissible=" + string(cand_adm.get("admissible") ?? false) + "|score=" + string(cand_adm.get("score") ?? -1) + "|threshold=" + string(cand_adm.get("threshold") ?? -1) + "|reason=" + string(cand_adm.get("reason") ?? "") + "|len=" + string(len(string(candidates[cai].get("content") ?? "")))) - cai = cai + 1 - } - - if propose_candidates > 0 { - let selected = orchestra.select_admissible(proposals) - print("PROPOSE_SELECT|admissible_count=" + string(selected.get("admissible_count") ?? -1) + "|selected_index=" + string(selected.get("selected_index") ?? -1) + "|total=" + string(selected.get("total") ?? -1)) - // select_admissible returns {selected, selected_index, - // admissible_count, total}; "selected" is a candidate dict or - // null when nothing cleared the threshold. - let best = selected.get("selected") - if best != null { - let committed = agent.commit(handles["developer"], best) - propose_committed = true - track("developer", committed) - post_send_check(handles["developer"], committed, "developer") - let committed_code = agent.extract_code(committed.get("content") ?? "", "python") - if len(committed_code) > 20 { - pipeline_code = committed_code - } - print("PROPOSE_SELECT|committed=true") + print("PROPOSE_SELECT|error=" + string(e)) + cycle_done = true } } + } - if len(pipeline_code) > 20 { - try { - codegen.run_strict("python", "import ast; ast.parse('''" + pipeline_code + "''')") - codegen_result = "pass" - } catch (e) { - codegen_result = "fail" - print("PROPOSE_SELECT|codegen_error=" + string(e)) - } + // Outside the retry unit: this validates pipeline_code, which is + // unchanged by a failed cycle, so it must not be re-run per attempt. + if len(pipeline_code) > 20 { + try { + codegen.run_strict("python", "import ast; ast.parse('''" + pipeline_code + "''')") + codegen_result = "pass" + } catch (e) { + codegen_result = "fail" + print("PROPOSE_SELECT|codegen_error=" + string(e)) } - } catch (e) { - print("PROPOSE_SELECT|error=" + string(e)) } } diff --git a/src/stdlib/agent_impl.cpp b/src/stdlib/agent_impl.cpp index afdd3480..afb2c1ae 100644 --- a/src/stdlib/agent_impl.cpp +++ b/src/stdlib/agent_impl.cpp @@ -5150,6 +5150,7 @@ static NaabVal agentCommit(std::vector& args) { std::string user_message = selected.user_message; int current_turn = 0; + bool lease_expired = false; { std::lock_guard lock(s_agent_mutex); auto tracker_it = s_trackers.find(handle_id); @@ -5165,11 +5166,44 @@ static NaabVal agentCommit(std::vector& args) { " Got: {} turns used\n", config->max_turns, tracker_it->second.turns)); } + // Authority is time-varying; a proposal is not. propose() gates the + // lease when the candidates are generated, but commit is the point the + // transition becomes real, and the deliberation gap between the two is + // the whole purpose of propose/commit. Only the wall-clock half can + // actually fire here: turns cannot advance between propose and commit, + // so a turn-based lease that passed propose is still valid by + // construction. Evaluated under the lock, enforced outside it — same + // shape as agentPropose(). + lease_expired = leaseExpiredLocked(tracker_it->second, config); current_turn = tracker_it->second.turns; } auto* gov_engine = governance::GovernanceEngine::getCurrent(); + if (lease_expired) { + const auto& cb_cfg = (gov_engine && gov_engine->isActive()) + ? gov_engine->getRules().circuit_breaker + : governance::CircuitBreakerConfig{}; + if (cb_cfg.step_up_enabled) { + // Renewable: agent.send() resets both lease halves on a passed + // challenge. Note the send also invalidates outstanding proposals, + // so the caller must re-propose rather than retry this commit. + throw std::runtime_error( + "Agent error: agent.commit denied — step-up challenge required\n\n" + " Help:\n" + " - The standing lease expired while the proposal was pending\n" + " - Call agent.send() to re-authorize, then propose again\n" + " - Re-authorizing discards pending proposals by design\n"); + } + throw std::runtime_error( + "Agent error: Standing lease expired before commit\n\n" + " Help:\n" + " - The proposal was generated while the lease was valid," + " but it expired before it was committed\n" + " - Create a new agent handle for a fresh conversation\n" + " - Or enable step_up challenges to allow lease renewal\n"); + } + // Post-receive pipeline: BSD response event + CDD at the pre-increment turn if (gov_engine && gov_engine->isActive() && gov_engine->getRules().behavioral_sequences.enabled) { diff --git a/tests/governance_v4/test_propose_commit.sh b/tests/governance_v4/test_propose_commit.sh index e622400d..703b4e51 100755 --- a/tests/governance_v4/test_propose_commit.sh +++ b/tests/governance_v4/test_propose_commit.sh @@ -425,6 +425,100 @@ else fail "F-03" "No-lease config fell through to permanently allowed" "got: ${R_NOLEASE:-}" fi +# ============================================================ +# Group G: the lease is enforced at the COMMIT boundary, not just at propose +# +# propose() gates the lease when candidates are generated, but commit is where +# the transition becomes real and the deliberation gap between them is the +# point of propose/commit. 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 — without it the gate could be over-broad and nothing would notice. +# ============================================================ +echo "" +echo -e "${CYAN}--- Group G: lease enforced at the commit boundary ---${NC}" + +write_govern_commit_lease() { # $1=workdir $2=port $3=lease_seconds $4=lease_turns $5=step_up + cat > "$1/govern.json" << GOVEOF +{ + "mode": "enforce", + "security": { "sandbox_level": "elevated" }, + "telemetry": { "enabled": true, "output_file": "telemetry.jsonl" }, + "circuit_breaker": { "enabled": true, "step_up_enabled": $5 }, + "agents": { + "proposer": { + "provider": "gemini", + "model": "stub-model", + "api_base": "http://127.0.0.1:$2", + "api_key_env": "FAKE_KEY_PROPOSE_TEST", + "max_tokens": 100, + "max_turns": 30, + "propose_candidates_max": 2, + "standing_lease_seconds": $3, + "standing_lease_turns": $4 + } + } +} +GOVEOF + sign_govern "$1" +} + +# Propose, let the wall clock run past the lease, then commit. +run_commit_lease_case() { # $1=name $2=lease_seconds $3=lease_turns $4=step_up + local d="$TEST_TMP/glease-$1"; mkdir -p "$d" + cp "$WDIR/fixture.json" "$d/fixture.json" + start_stub "$d/fixture.json" "$d" >/dev/null 2>&1 || { echo "STUB_FAIL"; return; } + write_govern_commit_lease "$d" "$STUB_PORT" "$2" "$3" "$4" + cat > "$d/t.naab" << 'EOF' +use agent +main { + let h = agent.create("proposer") + let p = agent.propose(h, "summarize quarterly revenue", 1) + let cands = p.get("candidates") ?? [] + if cands.length() == 0 { print("NO_CANDIDATES") } + time.sleep(4) + try { + let c = agent.commit(h, cands[0]) + print("COMMIT_OK|turns=" + string(agent.usage(h).get("turns") ?? 0)) + } catch (e) { + print("COMMIT_DENIED|stepup=" + string(string(e).contains("step-up"))) + } +} +EOF + local out + out=$( (cd "$d" && timeout 90s "$NAAB" t.naab 2>/dev/null) \ + | grep -oE 'COMMIT_OK\|turns=[0-9]+|COMMIT_DENIED\|stepup=(true|false)|NO_CANDIDATES' | tail -1 ) + echo "${out:-}" + stop_stub +} + +# G-01: wall-clock lease expires during the gap -> commit must refuse, and the +# message must carry the "step-up" substring the re-auth pattern matches on. +R_G1=$(run_commit_lease_case wallclock 2 0 true) +if echo "$R_G1" | grep -q 'COMMIT_DENIED|stepup=true'; then + pass "G-01" "Wall-clock lease expiry blocks commit with the re-auth message" +else + fail "G-01" "Commit landed under an expired wall-clock lease" "got: $R_G1" +fi + +# G-02: turn-based lease only -> commit must still succeed. Guards the no-op +# claim; a failure here means the gate is firing where it provably cannot. +R_G2=$(run_commit_lease_case turnonly 0 20 true) +if echo "$R_G2" | grep -q 'COMMIT_OK|turns=1'; then + pass "G-02" "Turn-only lease still commits (gate is not over-broad)" +else + fail "G-02" "Turn-lease commit was refused — gate is over-broad" "got: $R_G2" +fi + +# G-03: step-up disabled -> no re-auth path exists, so the refusal must use the +# hard wording rather than telling the caller to run a challenge it cannot run. +R_G3=$(run_commit_lease_case nostepup 2 0 false) +if echo "$R_G3" | grep -q 'COMMIT_DENIED|stepup=false'; then + pass "G-03" "Step-up disabled yields the hard lease-expired refusal" +else + fail "G-03" "Wrong refusal wording with step-up disabled" "got: $R_G3" +fi + # ============================================================ echo "" echo -e "${CYAN}==============================================${NC}"