Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ See `docs/runtime/ExecutionRouting.md` for detailed execution flow and resilienc
|----------|-------------|---------|
| `NEXO_BACKGROUND_AGENTS_CONFIG` | Path to background agent set JSON configuration | unset |
| `NEXO_AGENT_MODE_PATH` | Path to file-based aggressiveness mode store | unset |
| `NEXO_EXTENSION_MAX_LINEAGE_DEPTH` | Extender ceiling (SX-AUDIT invariant D): max `ParentId` hops below a human-authored root an extender may sit and still extend. May only LOWER the built-in default. | 1 |
| `NEXO_EXTENSION_MAX_UNATTENDED_CYCLES` | Extender ceiling: extend cycles since a human last armed the agent before it holds (re-arm: restart or `RearmExtension`). May only LOWER the default. | 8 |
| `NEXO_EXTENSION_MAX_CYCLES_PER_HOUR` | Extender ceiling: extend cycles in any trailing hour. May only LOWER the default. | 4 |
| `NEXO_OBSERVATION_DEGRADED_MODE` | `1` = start observation pipeline in degraded mode | unset |
| `NEXO_OBSERVATION_FAIL_OPEN` | `1` = observation pipeline continues on store errors | unset |
| `NEXO_BARRIER_MIDDLEWARE_ENABLED` | `1` = enable HTTP barrier context middleware | unset |
Expand Down
26 changes: 15 additions & 11 deletions docs/SELF-EXTEND-AUDIT.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Self-Extend Safety Audit (SX-AUDIT / SX-ENFORCE)

**Sprint:** SX-AUDIT (characterize) + SX-ENFORCE (A/B/C teeth)
**Branch:** `cursor/self-extend-enforce-6118` (off `cursor/self-extend-audit-6118`)
**Sprint:** SX-AUDIT (characterize) + SX-ENFORCE (A/B/C teeth) + SX-ENFORCE-D (D ceiling, 2026-08-16)
**Branch:** `cursor/self-extend-enforce-6118` (off `cursor/self-extend-audit-6118`); D on `feat/sx-invariant-d-extension-ceiling`
**Scope:** Background-agent self-extend path only (not CLI `nexo self-extend`, not orchestration `LifecycleManager`).

## Purpose

Trace the **executing** control flow for background extender agents and record whether four safety invariants are enforced. Invariant **D** remains deferred.
Trace the **executing** control flow for background extender agents and record whether four safety invariants are enforced. All four are now enforced on the live path (D closed by SX-ENFORCE-D, 2026-08-16).

## Control-flow trace

Expand Down Expand Up @@ -34,7 +34,11 @@ Scheduler tick → TrackedExecuteAgentAsync [BackgroundAgentRegistr
│ default Passive when store missing [BackgroundAgentRegistry.cs:518]
├─ Passive → skip (C) [BackgroundAgentRegistry.cs:519–525]
├─ SemiActive → IApprovalGate (deny if null) [BackgroundAgentRegistry.cs:528–544]
└─ ISelfExtendRunner.RunAsync(+ agentId) [BackgroundAgentRegistry.cs:555–557]
├─ ExtensionCeiling.NarrowedBy(config) +
│ ExtensionLedger.Refusal(depth/rate/unattended) (D)
│ → refuse: log Warning + observation + telemetry, no runner call
│ → else ledger.RecordCycle() [BackgroundAgentRegistry.cs extender branch]
└─ ISelfExtendRunner.RunAsync(+ agentId) [BackgroundAgentRegistry.cs]
└─ SelfExtendRunnerAdapter.RunAsync [SelfExtendRunnerAdapter.cs:74–196]
├─ RepoFsToolboxFactory.CreateWithBuildTest
│ [SelfExtendRunnerAdapter.cs:116–131]
Expand Down Expand Up @@ -64,7 +68,7 @@ Runtime agent activation (separate from extend cycle):
| A | Cert-gate inheritance | **ENFORCED** | `SelfProducedBrickCertificationPolicy.Approve` on self-extend writes when `selfExtendAdmission=true`; verifies admitted record via `CertificationTrustVerifier` (content-bound) [`SelfProducedBrickCertificationPolicy.cs:24–78`, wired `RepoFsToolboxFactory.cs:137–140`]. Missing store → `FailClosedCertificationRecordStore` denies all brick admissions. Human boot roots unaffected (no self-extend snapshot). | `SelfExtendInvariantACertGateTests` |
| B | Monotonic policy narrowing | **ENFORCED** | `AgentPolicyNarrowingValidator.ValidateOrThrow` at `RegisterAsync` for `ParentId` children [`BackgroundAgentRegistry.cs:203–206`, `AgentPolicyNarrowingValidator.cs`]. Self-extend snapshot sets `agentId` + wires `DataExfiltrationPolicy` [`SelfExtendRunnerAdapter.cs:224–232`, `RepoFsToolboxFactory.cs:142–145`]. Roots without `ParentId` skip narrowing (trust root). | `SelfExtendInvariantBPolicyNarrowingTests` |
| C | Fail-closed default | **ENFORCED** | `FileBasedAggressivenessModeStore` missing/corrupt file → **Passive** [`FileBasedAggressivenessModeStore.cs:33–34`, `41–44`]. Registry fallback when mode store absent → **Passive** [`BackgroundAgentRegistry.cs:518`]. SemiActive without `IApprovalGate` → denied [`BackgroundAgentRegistry.cs:528–535`]. Explicit Active or approved SemiActive → runs. | `SelfExtendInvariantCHumanAdmissionTests` |
| D | Recursion / runaway ceiling | **GAP** (partial per-cycle caps) | Per-cycle: `ToolCallingAgent.DefaultMaxIterations`, `BuildTestBudget`. **No** cross-cycle extender depth/rate refusal. | `SelfExtendInvariantDRecursionCeilingTests` — rejection **SKIP (GAP)** |
| D | Recursion / runaway ceiling | **ENFORCED** | Per-cycle: `ToolCallingAgent.DefaultMaxIterations`, `BuildTestBudget`. Cross-cycle: `ExtensionCeiling` (lineage depth, unattended cycles since human arm, cycles per trailing hour) enforced by `BackgroundAgentRegistry` after the mode gate and before `ISelfExtendRunner.RunAsync` [`ExtensionCeiling.cs`, `ExtensionLedger.cs`, `BackgroundAgentRegistry.cs` extender branch]. Overrides (env, agent `Parameters`) may only LOWER; ledgers live outside the agent instance so re-registration cannot reset them; re-arm is `RearmExtension` (operator surface) or restart. Refusals are logged (Warning), observed (`stopped_reason=extension_ceiling`), and carried in cycle telemetry. | `SelfExtendInvariantDRecursionCeilingTests` — rejection **PASSES** |

### Invariant A — cert-gate inheritance

Expand All @@ -86,13 +90,13 @@ Unconfigured aggressiveness defaults to **Passive** — extender cycles do not r

### Invariant D — recursion / runaway ceiling

**Verdict: GAP** (deferred)
**Verdict: ENFORCED**

Within-cycle ReAct and build/test budgets exist. Cross-cycle extender recursion depth is not capped.
Within-cycle ReAct and build/test budgets exist. Across cycles, `ExtensionCeiling` refuses an extend cycle when any of three ceilings is reached — `MaxLineageDepth` (default 1: roots and their direct children may extend, machine-spawned grandchildren may not; depth = `ParentId` hops, an unresolvable parent still counts), `MaxUnattendedCycles` (default 8 since a human last armed the agent, then hold), `MaxCyclesPerHour` (default 4, trailing hour). The environment (`NEXO_EXTENSION_MAX_LINEAGE_DEPTH`, `NEXO_EXTENSION_MAX_UNATTENDED_CYCLES`, `NEXO_EXTENSION_MAX_CYCLES_PER_HOUR`) and an agent's own `Parameters` (`MaxLineageDepth`, `MaxUnattendedCycles`, `MaxCyclesPerHour`) may only lower these — the same posture as the certified loop's `RecursionDiscipline`; raising a default is a code change. Only cycles that actually reach the runner consume budget (a Passive skip or approval denial does not). Re-arm is an operator act: `BackgroundAgentRegistry.RearmExtension(agentId)` or a process restart; re-registration deliberately does not re-arm because agents can re-register themselves (`UpdateAgentConfigTool`), and re-arm clears the unattended count but not the trailing-hour rate. A CLI verb for re-arm is a follow-up on an `application/*` branch.

## REORDER note (post SX-ENFORCE)
## REORDER note (post SX-ENFORCE-D)

Invariants **A, B, and C** are now enforced on the live path. **D** (cross-cycle extender recursion ceiling) remains the primary deferred safety item before treating unattended multi-cycle self-extension as production-safe.
Invariants **A, B, C and D** are now enforced on the live path. Unattended multi-cycle self-extension is bounded: an Active extender runs at most `MaxUnattendedCycles` cycles before a human must re-arm it, at most `MaxCyclesPerHour` in any hour, and only within `MaxLineageDepth` of a human-authored root. What remains open is not a ceiling but convergence: the legacy extender path and the certified autonomy loop are two self-extension paths, and the long-term intent is one.

## Test index

Expand All @@ -101,7 +105,7 @@ Invariants **A, B, and C** are now enforced on the live path. **D** (cross-cycle
| `src/Nexo.Tests.BackgroundAgents/SelfExtend/SelfExtendInvariantACertGateTests.cs` | Invariant A |
| `src/Nexo.Tests.BackgroundAgents/SelfExtend/SelfExtendInvariantBPolicyNarrowingTests.cs` | Invariant B |
| `src/Nexo.Tests.BackgroundAgents/SelfExtend/SelfExtendInvariantCHumanAdmissionTests.cs` | Invariant C |
| `src/Nexo.Tests.BackgroundAgents/SelfExtend/SelfExtendInvariantDRecursionCeilingTests.cs` | Invariant D (GAP) |
| `src/Nexo.Tests.BackgroundAgents/SelfExtend/SelfExtendInvariantDRecursionCeilingTests.cs` | Invariant D |
| `src/Nexo.Tests.BackgroundAgents/SelfExtend/SelfExtendAuditTestSupport.cs` | Shared helpers |

Invariant D rejection test retains `[Fact(Skip = "GAP: …")]` until a future sprint adds cross-cycle ceiling enforcement.
The invariant D rejection test is live (no `Skip`); it was the last `GAP` marker in the suite.
124 changes: 124 additions & 0 deletions src/Nexo.BackgroundAgents/Extending/ExtensionCeiling.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Globalization;
using Nexo.BackgroundAgents.Configuration;

namespace Nexo.BackgroundAgents.Extending;

/// <summary>
/// SX-AUDIT invariant D — the cross-cycle ceilings on extender-triggered extension. Enforced by
/// <c>BackgroundAgentRegistry</c> at the same point invariant C is (before the self-extend runner
/// is invoked), so an extender that is Active still cannot extend without bound.
///
/// <para>Three ceilings, each refusing a cycle on its own:</para>
/// <list type="bullet">
/// <item><description><see cref="MaxLineageDepth"/> — recursion. How many <c>ParentId</c> hops
/// below a human-authored root a machine-spawned extender may sit and still run extend cycles.
/// Extension that spawns extenders that extend is exactly the runaway this bounds.</description></item>
/// <item><description><see cref="MaxUnattendedCycles"/> — runaway. Extend cycles an extender may
/// run since a human last armed it before it holds. Re-arm is an operator act: process
/// restart or <c>BackgroundAgentRegistry.RearmExtension</c>; re-registration does NOT re-arm,
/// because agents can re-register themselves.</description></item>
/// <item><description><see cref="MaxCyclesPerHour"/> — rate. Extend cycles in any trailing hour.</description></item>
/// </list>
///
/// <para>Overrides may only LOWER a ceiling, never raise it — the same posture as the certified
/// loop's <c>RecursionDiscipline</c>: the environment may be stricter than this file, and an
/// agent's own <c>Parameters</c> may be stricter than the environment, so no configuration an
/// agent can write extends its own authority (I-1). Raising a default is a code change.</para>
/// </summary>
public sealed record ExtensionCeiling(int MaxLineageDepth, int MaxUnattendedCycles, int MaxCyclesPerHour)
{
/// <summary>Environment variable that may LOWER <see cref="MaxLineageDepth"/>.</summary>
public const string LineageDepthEnvVar = "NEXO_EXTENSION_MAX_LINEAGE_DEPTH";

/// <summary>Environment variable that may LOWER <see cref="MaxUnattendedCycles"/>.</summary>
public const string UnattendedCyclesEnvVar = "NEXO_EXTENSION_MAX_UNATTENDED_CYCLES";

/// <summary>Environment variable that may LOWER <see cref="MaxCyclesPerHour"/>.</summary>
public const string CyclesPerHourEnvVar = "NEXO_EXTENSION_MAX_CYCLES_PER_HOUR";

/// <summary>Per-agent <c>Parameters</c> key that may LOWER <see cref="MaxLineageDepth"/>.</summary>
public const string LineageDepthParameter = "MaxLineageDepth";

/// <summary>Per-agent <c>Parameters</c> key that may LOWER <see cref="MaxUnattendedCycles"/>.</summary>
public const string UnattendedCyclesParameter = "MaxUnattendedCycles";

/// <summary>Per-agent <c>Parameters</c> key that may LOWER <see cref="MaxCyclesPerHour"/>.</summary>
public const string CyclesPerHourParameter = "MaxCyclesPerHour";

/// <summary>The trailing window <see cref="MaxCyclesPerHour"/> is measured over.</summary>
public static readonly TimeSpan RateWindow = TimeSpan.FromHours(1);

/// <summary>
/// The defaults. Roots and their direct children may extend; grandchildren may not. Eight
/// unattended cycles, then hold for a human. Four cycles in any hour. Changing these
/// constants is a reviewed code change, deliberately.
/// </summary>
public static ExtensionCeiling Default { get; } = new(MaxLineageDepth: 1, MaxUnattendedCycles: 8, MaxCyclesPerHour: 4);

/// <summary>The defaults, lowered by whatever the environment sets — never raised.</summary>
public static ExtensionCeiling Resolve() => Resolve(Environment.GetEnvironmentVariable);

/// <summary>Testable form of <see cref="Resolve()"/>.</summary>
public static ExtensionCeiling Resolve(Func<string, string?> environment)
{
ArgumentNullException.ThrowIfNull(environment);
return Default.LoweredBy(
ParseNonNegative(environment(LineageDepthEnvVar)),
ParseNonNegative(environment(UnattendedCyclesEnvVar)),
ParseNonNegative(environment(CyclesPerHourEnvVar)));
}

/// <summary>
/// This ceiling lowered by an agent's own <c>Parameters</c>. An agent may ask for less
/// authority than the deployment grants; a value above the current ceiling is ignored.
/// </summary>
public ExtensionCeiling NarrowedBy(BackgroundAgentConfig config)
{
ArgumentNullException.ThrowIfNull(config);
return LoweredBy(
ReadParameter(config, LineageDepthParameter),
ReadParameter(config, UnattendedCyclesParameter),
ReadParameter(config, CyclesPerHourParameter));
}

private ExtensionCeiling LoweredBy(int? lineageDepth, int? unattendedCycles, int? cyclesPerHour) => new(
Lower(MaxLineageDepth, lineageDepth),
Lower(MaxUnattendedCycles, unattendedCycles),
Lower(MaxCyclesPerHour, cyclesPerHour));

private static int Lower(int current, int? candidate) =>
candidate is { } c && c >= 0 && c < current ? c : current;

private static int? ReadParameter(BackgroundAgentConfig config, string key)
{
if (config.Parameters is null || !config.Parameters.TryGetValue(key, out var raw) || raw is null)
return null;
return raw switch
{
int i => i,
long l when l <= int.MaxValue && l >= int.MinValue => (int)l,
string s => ParseNonNegative(s),
System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Number } je when je.TryGetInt32(out var n) => n,
System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.String } je => ParseNonNegative(je.GetString()),
IConvertible conv => SafeConvert(conv),
_ => null,
};
}

private static int? SafeConvert(IConvertible value)
{
try
{
return value.ToInt32(CultureInfo.InvariantCulture);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
return null;
}
}

private static int? ParseNonNegative(string? raw) =>
int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value >= 0
? value
: null;
}
104 changes: 104 additions & 0 deletions src/Nexo.BackgroundAgents/Extending/ExtensionLedger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
namespace Nexo.BackgroundAgents.Extending;

/// <summary>
/// Per-extender bookkeeping behind <see cref="ExtensionCeiling"/>: cycles run since a human
/// last armed the agent, and the timestamps of recent cycles for the hourly rate. Kept by the
/// registry OUTSIDE the agent instance so that re-registering an agent (which agents can do to
/// themselves through <c>UpdateAgentConfigTool</c>) does not reset it — only
/// <see cref="Rearm"/> or a process restart does.
/// </summary>
public sealed class ExtensionLedger
{
private readonly object _gate = new();
private readonly Queue<DateTimeOffset> _recent = new();
private readonly Func<DateTimeOffset> _clock;

/// <summary>Creates a ledger over the given clock (UTC now by default).</summary>
public ExtensionLedger(Func<DateTimeOffset>? clock = null)
{
_clock = clock ?? (static () => DateTimeOffset.UtcNow);
}

/// <summary>Extend cycles run since the last human arm (or process start).</summary>
public int UnattendedCycles { get; private set; }

/// <summary>Extend cycles in the trailing <see cref="ExtensionCeiling.RateWindow"/>.</summary>
public int CyclesInWindow
{
get
{
lock (_gate)
{
Prune(_clock());
return _recent.Count;
}
}
}

/// <summary>
/// The reason this agent may not run another extend cycle under <paramref name="ceiling"/>,
/// or null when it may. Lineage depth is the caller's to compute (it is a property of the
/// registry's parent graph, not of this ledger).
/// </summary>
public string? Refusal(ExtensionCeiling ceiling, int lineageDepth)
{
ArgumentNullException.ThrowIfNull(ceiling);
if (lineageDepth > ceiling.MaxLineageDepth)
{
return $"lineage depth {lineageDepth} exceeds MaxLineageDepth {ceiling.MaxLineageDepth} " +
"(a machine-spawned extender this far below a human root may not extend)";
}

lock (_gate)
{
var now = _clock();
Prune(now);
if (_recent.Count >= ceiling.MaxCyclesPerHour)
{
return $"{_recent.Count} extend cycle(s) in the trailing hour reach MaxCyclesPerHour {ceiling.MaxCyclesPerHour}";
}

if (UnattendedCycles >= ceiling.MaxUnattendedCycles)
{
return $"{UnattendedCycles} unattended extend cycle(s) reach MaxUnattendedCycles {ceiling.MaxUnattendedCycles}; " +
"holding until a human re-arms (restart, or RearmExtension)";
}
}

return null;
}

/// <summary>Records that an extend cycle was actually handed to the runner.</summary>
public void RecordCycle()
{
lock (_gate)
{
var now = _clock();
Prune(now);
_recent.Enqueue(now);
UnattendedCycles++;
}
}

/// <summary>
/// A human re-arms the agent: the unattended count resets. The hourly rate does not — a
/// human's blessing does not make the last hour's cycles un-happen.
/// </summary>
/// <returns>The unattended count that was cleared.</returns>
public int Rearm()
{
lock (_gate)
{
var cleared = UnattendedCycles;
UnattendedCycles = 0;
return cleared;
}
}

private void Prune(DateTimeOffset now)
{
var horizon = now - ExtensionCeiling.RateWindow;
while (_recent.Count > 0 && _recent.Peek() < horizon)
_recent.Dequeue();
}
}
Loading
Loading