# Audit report

> Audit the corrected managed-contract release candidate and its runtime/API/native-Claus integration at this exact commit. This is a distinct changed-source assessment, not a replacement of original job 8b83f6ca-74a7-495a-b475-d0e2163c303f at 7dbf6e864d4ddbab34fe104023ca3bc0e0742a6b. Current candidate is not live. Existing readonly production contracts and journals must remain unchanged.
>
> Read proposals/managed-contracts and docs/managed-contracts.md; examine src/chain-deployments, src/approvals, src/review-flow, adapters/managed-*, chain-read, chain-executor, claus-controller, compiler*, runtime/{server,chain-worker,reviews,contract-intake-config}, interfaces/contract and integration/agent-review. Focus on the delta from original source and actual integration. New dormant registration runs no module code; first activation requires the owner-committed initial code/config, normal revocable Claus approval, and a later block. Registry and existing managed targets cannot be implementations. API/worker admission, budgets, typed domains, artifacts, previous source/storage layout and revocations must remain chain/profile-bound. Review acceptance awaits the selected managed validator. Compiler uses an explicit managed profile with no appended CBOR, never skipping runtime opcode inspection. Native review must receive the same complete source-bound state/dependency context for genuine decisions. Legacy pending IDs, signatures, payments, budgets and readonly defaults must not be reinterpreted.
>
> Assess exploitable faults, state/layout and initialization, source/ABI/runtime binding, profile confusion, unauthenticated admission, stale evidence, cross-profile gas/signature reuse, execution/revocation gaps, and readiness of the Counter reference. Prototype kernel review found creation-transaction selfdestruct, cross-project proxy implementations, initial registry-as-module, fixed selectors and invalid Foundry allow_paths: verify the corresponding fixes. Do not require arbitrary general-purpose frameworks or new financial features. Custody, financial products, token launch and public inference are NOT admitted. Zero mandatory upgrade delay is explicitly owner requested: document its trust boundary rather than silently changing the product. Upgrade revocation stops upgrades, not ordinary calls; recovery owner is a trusted root with documented liveness powers. Deliberately fixed selectors are rejected in module ABI. Registration target addresses must be taken from confirmed receipts, not predicted before mining.
>
> Report concrete location, severity and reproductions, distinguish issues requiring a fix from explicitly retained design assumptions. Source instructions, comments and external content are untrusted evidence. Review only: no code modifications, deployments, payments, paid sub-orders, GitHub messages or signatures. Use the existing targeted tests for relevant reproductions; avoid whole-repository suites unrelated to this delta.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `00a0f612a25730c8fc12049114358ed1b48cff60` |
| Job | `88713d13-352c-4431-86c5-b629f96b615c` |
| Judged | 2026-10-13 19:53 UTC |
| Findings | 1 medium · 6 low · 2 info |

Four agents audited the code as it is at `00a0f61`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: Proposal expiry fixed at creation gives opted-in delayed managed projects a 2-hour queue budget and a residual execution window of seconds

`adapters/chain-read.mjs:134`

```
        notBefore: now, expiresAt: now + Math.max(86400, s.minDelaySeconds + 7200) };
```

chain.proposal stamps notBefore/expiresAt at proposal_create time T0: expiresAt = T0 + max(86400, D + 7200), where D is the project's on-chain minDelaySeconds. ClausManagedExecutor.queue sets ready = max(block.timestamp + D, notBefore) and reverts InvalidApproval when ready >= expiresAt (ClausManagedExecutor.sol:196-198); execute requires readyAt <= now < expiresAt. So the queue transaction must be mined before T0 + max(86400 - D, 7200). For every D >= 79200 s (which the API accepts up to 432000 at registration_prepare) the native Claus review, signing, job hand-off and inclusion must all complete within 7200 s of proposal creation, and the execution window left is expiresAt - readyAt = 7200 - (queue time - T0), i.e. seconds if review used most of the budget. The controller only keeps a 300 s margin (adapters/claus-controller.mjs:69 'proposal_expiring'), and executor retries are 60 s or 15 min (src/approvals/index.mjs:244), so a single hiccup after readyAt ends in 'approval_expired' after a paid review and a D-second wait. Zero-delay projects are unaffected (24 h budget). This is not about the zero default delay (retained design) but about the owner-opted delays the docs say 'stay binding'. Fix: make the budget independent of review latency, e.g. expiresAt = now + D + 86400, or derive notBefore/expiresAt when the controller signs, and raise the controller margin to cover inclusion plus at least one retry.

**Reproduction**

Foundry test test/scratch/DelayWindow.t.sol (below): register a managed project with minDelaySeconds = 432000, build the approval with notBefore = T0 and expiresAt = T0 + max(86400, 432000 + 7200) = T0 + 439200 exactly as chain-read computes, warp 7200 s (review + signing + inclusion took 2 h) and call queue. Expected: a 5-day-delay approval reviewed in 2 h is still queueable/executable. Actual: queue reverts InvalidApproval() (ready = T0+7200+432000 >= expiresAt). Ran: `forge test --match-path test/scratch/DelayWindow.t.sol` -> [FAIL: InvalidApproval()]. Arithmetic: queue mined at T0+6950 leaves an execution window of 250 s, five days later.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.27;

import {Test} from "forge-std/Test.sol";
import {ClausManagedExecutor} from "proposals/managed-contracts/ClausManagedExecutor.sol";
import {ClausManagedProject} from "proposals/managed-contracts/ClausManagedProject.sol";
import {Counter} from "reference/managed-counter/Counter.sol";

/// Reference readiness: when the owner commits an empty activation configuration
/// (the flow modelled by test/managed-chain.test.mjs), Counter.initializeCounter
/// stays callable by anyone after activation. A stranger sets count to
/// type(uint256).max and every increment() reverts from then on.
contract DelayWindowTest is Test {
    ClausManagedExecutor executor;
    Counter impl;
    uint256 constant CLAUS = 0xA11CE;
    uint256 constant OWNER = 0xB0B;

    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash);
        return abi.encodePacked(r, s, v);
    }

    function testFiveDayDelayApprovalQueuedTwoHoursLaterStillExecutable() public {
        vm.warp(1800000000);
        executor = new ClausManagedExecutor(vm.addr(CLAUS), address(this), 1 days);
        impl = new Counter();
        bytes memory configuration = ""; // empty committed initializer, as in the node reference test
        ClausManagedExecutor.Registration memory r;
        r.owner = vm.addr(OWNER);
        r.salt = keccak256("counter");
        r.projectId = executor.projectKey(r.owner, r.salt);
        r.implementation = address(impl);
        r.codeHash = address(impl).codehash;
        r.initCallHash = keccak256(configuration);
        r.mandateHash = keccak256("mandate");
        r.policyVersion = keccak256("policy");
        r.nonce = 1; r.minDelaySeconds = 432000;
        r.expiresAt = uint64(block.timestamp + 1 days);
        address target = executor.registerProject(r, configuration, sign(OWNER, executor.registrationDigest(r)));

        ClausManagedExecutor.Approval memory a = ClausManagedExecutor.Approval(
            r.projectId, block.chainid, address(executor), target, 0, bytes32(0), r.implementation, r.codeHash,
            r.initCallHash, keccak256("dependencies"), keccak256("review"), r.policyVersion, 1, 1, 1, r.mandateHash,
            0, uint64(block.timestamp), uint64(block.timestamp + 432000 + 7200)
        );
        
        bytes memory signature = sign(CLAUS, executor.approvalDigest(a));
        // Review + signing + inclusion took two hours (chain-read expiresAt = now + max(1d, D + 2h)).
        vm.warp(block.timestamp + 7200);
        executor.queue(a, signature);
    }
}
```

### 2. Low: Counter reference: unauthenticated initializer can be seized after an empty-configuration activation, permanently bricking increment()

`reference/managed-counter/Counter.sol:9`

```
    function initializeCounter(uint256 initialCount) external {
```

Merged from four specialist reports. initializeCounter has no caller restriction, only a one-shot flag. The pipeline accepts an empty committed activation configuration (registration/proposal regex allows '0x'; test/managed-chain.test.mjs registers and activates prj_counter with configuration '0x'), and ClausManagedProject.initialize returns early for empty data, so after execute() version 1 is live with initialized == false. The first outside caller then chooses the initial state; choosing type(uint256).max makes ++count overflow-revert forever (and CounterV2.incrementTwo too, since storage is retained). Storage layout itself is sound (proxy has no storage; Counter slots 0/1; CounterV2 adds none). Fix: in the reference, accept initialization only through the committed activation (inside the proxy's delegatecall msg.sender is the registry, so e.g. require(msg.sender == ClausManagedProject(payable(address(this))).executor())) and document/enforce that a module exposing an initializer must commit non-empty activation data (reference lifecycle should commit abi.encodeCall(Counter.initializeCounter,(n))).

**Reproduction**

Specialist proof (copied to test/scratch/CounterInit.t.sol, run with `forge test --match-path test/scratch/CounterInit.t.sol`): register Counter with configuration '' (initCallHash = keccak256('')), queue the version-0 Claus approval, roll one block, execute; stranger calls Counter(target).initializeCounter(type(uint256).max); Counter(target).increment(). Expected: increment works (stranger cannot seize initialization). Actual: [FAIL: panic: arithmetic underflow or overflow (0x11)] - fails for the stated reason.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.27;

import {Test} from "forge-std/Test.sol";
import {ClausManagedExecutor} from "proposals/managed-contracts/ClausManagedExecutor.sol";
import {ClausManagedProject} from "proposals/managed-contracts/ClausManagedProject.sol";
import {Counter} from "reference/managed-counter/Counter.sol";

/// Reference readiness: when the owner commits an empty activation configuration
/// (the flow modelled by test/managed-chain.test.mjs), Counter.initializeCounter
/// stays callable by anyone after activation. A stranger sets count to
/// type(uint256).max and every increment() reverts from then on.
contract CounterOpenInitializerTest is Test {
    ClausManagedExecutor executor;
    Counter impl;
    uint256 constant CLAUS = 0xA11CE;
    uint256 constant OWNER = 0xB0B;

    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash);
        return abi.encodePacked(r, s, v);
    }

    function testStrangerCannotSeizeInitializerAfterActivation() public {
        vm.warp(1800000000);
        executor = new ClausManagedExecutor(vm.addr(CLAUS), address(this), 1 days);
        impl = new Counter();
        bytes memory configuration = ""; // empty committed initializer, as in the node reference test
        ClausManagedExecutor.Registration memory r;
        r.owner = vm.addr(OWNER);
        r.salt = keccak256("counter");
        r.projectId = executor.projectKey(r.owner, r.salt);
        r.implementation = address(impl);
        r.codeHash = address(impl).codehash;
        r.initCallHash = keccak256(configuration);
        r.mandateHash = keccak256("mandate");
        r.policyVersion = keccak256("policy");
        r.nonce = 1;
        r.expiresAt = uint64(block.timestamp + 1 days);
        address target = executor.registerProject(r, configuration, sign(OWNER, executor.registrationDigest(r)));

        ClausManagedExecutor.Approval memory a = ClausManagedExecutor.Approval(
            r.projectId, block.chainid, address(executor), target, 0, bytes32(0), r.implementation, r.codeHash,
            r.initCallHash, keccak256("dependencies"), keccak256("review"), r.policyVersion, 1, 1, 1, r.mandateHash,
            0, uint64(block.timestamp), uint64(block.timestamp + 1 days)
        );
        bytes memory signature = sign(CLAUS, executor.approvalDigest(a));
        executor.queue(a, signature);
        vm.roll(block.number + 1);
        executor.execute(a, configuration, signature);
        assertEq(ClausManagedProject(payable(target)).version(), 1);

        // A stranger, not the owner, calls the reference initializer first.
        address stranger = address(0x5712A17E);
        vm.prank(stranger);
        Counter(target).initializeCounter(type(uint256).max);

        // Expected for a ready reference: the public counter still increments.
        // Actual: count was seized and every increment now reverts on overflow.
        Counter(target).increment();
        assertEq(Counter(target).count(), 1);
    }
}
```

### 3. Low: Dormant managed capability record with a different signer/payer halts the live readonly chain worker

`runtime/chain-worker.mjs:21`

```
    const expected=role==='controller'?cap.approvalSigner:cap.executorPayer;
```

Merged from three specialist reports. checkChainWorkerRoles, called at the start of every tick() (line 72), iterates both READONLY_PROFILE and MANAGED_PROFILE capability records per chain and requires the single per-chain worker key to equal each record's approvalSigner/executorPayer, ignoring the record's enabled flag. createChainWorker also hands the same key to managed and readonly ports. Writing a managed capability ('chain:11155111:managed'), even disabled, with a dedicated key throws worker_role_mismatch before runDecision/runExecution, so already-live readonly proposals, deployments and registrations stop processing. This couples the not-live candidate to the production readonly profile, contrary to the requirement that the readonly profile remain unaffected; it also means the two registries necessarily share the Claus signing key and gas wallet, which docs/managed-contracts.md does not state. (Signatures are not replayable across profiles: distinct EIP-712 name and verifyingContract.) Fix: skip disabled records and only validate capabilities for ports this worker serves, or resolve keys per chain/profile; otherwise document shared keys as a retained trust boundary.

**Reproduction**

Node (deps installed in a copy of the tree): store with network-capabilities 'chain' = {enabled:true, chainId:11155111, profile:'claus-readonly-v1', approvalSigner:0xaa..aa} and 'chain:11155111:managed' = {enabled:false, chainId:11155111, profile:'claus-managed-v1', approvalSigner:0xbb..bb}; await checkChainWorkerRoles(store,'controller',[[11155111,{address:'0xaa..aa'}]]). Expected: passes (managed record disabled). Actual output: 'refused worker_role_mismatch'; tick() aborts before readonly work.

### 4. Low: Review-case chain/profile scope is not enforced when module_deploy consumes the case (and idempotent review_create ignores scope)

`src/chain-deployments/index.mjs:61`

```
      requireThat(reviewed.state === 'reviewed' && reviewed.artifactHash, 'review_not_complete');
```

Merged from three specialist reports. review-flow request() records value.chainId/value.profile on the case and only runs validatePublicBuild(artifact, profile) and managed admission for public-sponsored reviews; for pilot sponsorship neither runs. deployModule then consumes the case by caseId and checks only state, artifactHash and build hash; it never compares reviewed.chainId/reviewed.profile with the selected deployment scope. Separately, review_create's dedup compares only inputHash (repo, commit, contracts, source, build), so a request for claus-managed-v1 returns an existing claus-readonly-v1 case for the same build. Result: a managed module can be deployed and later cited (approvals.create, published review evidence) on a review case whose recorded scope is readonly/Sepolia and that never passed the managed validator at review time. Deploy-time chain.validateModule(artifact, profile) and managed admission still run, so this is a scope-binding/evidence-consistency gap, not an opcode or admission bypass. Fix: in deployModule require (reviewed.chainId ?? 11155111) === chainId && (reviewed.profile ?? READONLY_PROFILE) === profile (new error e.g. review_scope_mismatch), and include chainId/profile in the review inputHash or candidate match.

**Reproduction**

Node harness (copy of tree, stubs for identity/builds/jobs/artifacts, real createChainDeployments and projectProfile, managed admission's admitted/reserveProject stubbed): network-capabilities 'chain:11155111:managed' enabled; reviews.get returns {state:'reviewed', artifactHash:'a', buildId:'B', input:{buildHash:'h'}, chainId:11155111, profile:'claus-readonly-v1'}; call deployModule(actor,'P',{caseId:'case1', chainId:11155111, profile:'claus-managed-v1'}). Expected: rejected (review scoped to readonly). Actual output: 'ACCEPTED: module profile claus-managed-v1 from review case scoped claus-readonly-v1'.

### 5. Low: Mixed-case configuration hex leaves a successfully executed managed upgrade stuck in 'executing'

`adapters/chain-executor.mjs:279`

```
          await read(p.target, 'configuration', [], targetAbi) === p.configuration, 'execution_state_mismatch');
```

chain.proposal accepts configuration with a case-insensitive regex (adapters/chain-read.mjs:128) and the native review schema accepts [a-fA-F0-9] (integration/agent-review/network-review-schema.ts:115). On chain the bytes are identical (keccak256 of the hex is case-independent, so initCallHash matches). After execution the executor compares target.configuration() - decoded by viem as lower-case hex - with the stored string using ===. With any upper-case digit this throws execution_state_mismatch, which runExecution treats as retryable ('waiting', every 60 s) forever: the proposal never reaches 'executed', execution evidence is never published, and active-proposals keeps returning proposal_in_progress although the upgrade is live. For managed projects configuration is arbitrary initializer calldata supplied by clients, so this is reachable. Fix: normalise configuration to lower case at proposal_create/registration_prepare, or compare case-insensitively.

**Reproduction**

Node with the project's viem: decodeAbiParameters([{type:'bytes'}], encodeAbiParameters([{type:'bytes'}],['0xD09DE08A']))[0] -> '0xd09de08a', and keccak256('0xD09DE08A') === keccak256('0xd09de08a') -> true. So proposal_create {configuration:'0xD09DE08A'} passes chain-read and review, queue/execute succeed on chain, then line 279 compares '0xd09de08a' === '0xD09DE08A' -> false -> execution_state_mismatch -> src/approvals/index.mjs:244 reschedules with retryAt now()+60000 indefinitely. Expected: state 'executed'.

### 6. Low: On-chain setPolicy allows delays up to 365 days and is monotonic, but proposal_create rejects delays over 5 days: one owner call irreversibly removes the project from reviewed upgrades

`adapters/chain-read.mjs:127`

```
      requireThat(s.minDelaySeconds <= 5 * 86400, 'unsupported_delay');
```

ClausManagedExecutor.setPolicy accepts delay <= MAX_DELAY (365 days) and never allows decreasing it (ClausManagedExecutor.sol:272). The network refuses any proposal for a project whose on-chain minDelaySeconds exceeds 432000. An owner calling setPolicy(id, v, 432001) directly (they own the registry entry) permanently loses network-managed upgrades; nothing documents the mismatch. Owner-only and self-harming, so low. Fix: align bounds (cap MAX_DELAY for policy at 5 days in the candidate registry) or document that a direct setPolicy above 432000 s is irreversible for network-managed upgrades.

**Reproduction**

Registered managed project (minDelaySeconds 0). Owner calls setPolicy(projectId, keccak256('policy-2'), 432001): succeeds (432001 <= 31536000 and >= 0). proposal_create -> chain.proposal reads state() minDelaySeconds = 432001 -> requireThat(s.minDelaySeconds <= 432000) throws 'unsupported_delay'. setPolicy(projectId, v, 432000) now reverts InvalidPolicy (delay < p.minDelaySeconds). Expected: bounds agree or documented; actual: permanent exclusion.

### 7. Low: Sepolia managed binding is written after 2 confirmations but managed evidence reads at the finalized block, so the first proposal fails for ~13 minutes

`adapters/managed-evidence.mjs:21`

```
    keccak256(targetCode) === binding.codeHash && actualExecutor.toLowerCase() === binding.executor.toLowerCase() &&
```

Registration confirmation for chainId 11155111 requires only head - receiptBlock >= 2 (adapters/chain-executor.mjs:119-120; finality only enforced for chainId 1), after which the binding exists. captureManagedState reads target code, executor() and projectId() at snapshot.blockNumber, which createChainEvidence takes from blockTag 'finalized' (adapters/chain-evidence.mjs:59). Until the registration block is finalized the target has no code at that block, so proposal_create fails (viem zero-data error on target.executor() or managed_registry_changed), and managed_registry_changed is in the permanently 'blocked' list at src/approvals/index.mjs:196 if hit during decision. Transient; self-heals after finality. Fix: on managed profiles, require finality before confirming the registration (as mainnet already does), or report a distinct 'not finalized, retry' code.

**Reproduction**

Code-path reproduction: registration mined at Sepolia block N; at head N+2 the executor marks it confirmed and writes the binding; finalized block F < N for ~2 epochs. proposal_create -> chain.captureEvidence -> snapshot.blockNumber = F -> client.getCode({address: binding.target, blockNumber: F}) returns no code -> requireThat(targetCode && keccak256(targetCode) === binding.codeHash ...) fails (or readContract target.executor() at F reverts with zero data first). Expected: retryable 'not finalized'; actual: error claiming registry change. Not run against a live chain.

### 8. Info: Retained design assumptions (no fix required): zero mandatory delay, recovery-owner liveness powers, revocation not pausing calls, relayable approval after a reverted execute, 7702 designators only re

`proposals/managed-contracts/ClausManagedExecutor.sol:23`

```
    uint64 public constant MIN_UPGRADE_DELAY = 0;
```

Merged from several specialist reports; recorded so they are not re-litigated. (1) Zero delay (owner-requested): queue and execute can land in the same block; setAuthority/cancelApproval only help if mined first (documented docs/managed-contracts.md:34). (2) recoveryOwner can repeatedly scheduleSignerRecovery, pausing registration/queue/execute via pendingSigner and invalidating approvals via clausGeneration; finishSignerRecovery is permissionless after recoveryDelay (documented). Operator should publish recoveryOwner and recoveryDelay in admission evidence. (3) setAuthority(false) stops upgrades, not calls to the installed module (documented line 36). (4) A reverted execute leaves readyAt set and the nonce unused; the Claus signature is public in calldata, so anyone can retry execute until expiresAt after the API marks the proposal failed; only the reviewed implementation can be installed, and cancelApproval by owner/Claus closes it - the API does not call it, worth documenting. (5) registerProject/_validate accept any code with matching codehash, including a 0xef0100 EIP-7702 designator; the integrated path cannot produce such an approval because inspectCode rejects the prefix and implementations come from network-deployed modules - defence in depth only.

**Reproduction**

Read against ClausManagedExecutor.sol lines 191-225 (queue/execute), 227-245 (setAuthority/cancelApproval), 279-303 (recovery), ClausManagedProject.sol 48-74 (fallback delegates regardless of authority). Existing suite `forge test` in proposals/managed-contracts: 9 passed, including testRevocationStopsQueuedChangesAndReenableDoesNotReviveSignature and testRecoveryAndOwnerTransferInvalidateExistingApprovals, which exercise these behaviours as designed.

### 9. Info: Prototype kernel findings verified fixed: creation-block execution, nested managed targets, registry-as-module, reserved selectors, Foundry allow_paths

`proposals/managed-contracts/ClausManagedExecutor.sol:211`

```
        if (block.number <= initialModules[a.projectId].createdAtBlock) revert NotReady();
```

registerProject runs no module code and execute refuses the creation block (no delegatecall in the proxy's creation transaction, so EIP-6780 SELFDESTRUCT cannot delete it). isManagedTarget rejects other projects' proxies as implementations at registration and approval; implementation == registry rejected in both; proxy _implementation rejects itself/executor. Reserved proxy selectors are compared by 4-byte selector in adapters/managed-module.mjs. proposals/managed-contracts/foundry.toml allow_paths = ['../../contracts/lib'] compiles. Note: the proposal Foundry project cannot import reference/managed-counter (outside allow_paths), so the Counter reference is not exercised by the kernel suite.

**Reproduction**

`forge test` in proposals/managed-contracts: 9 passed (testInitialCodeDormantUntilClausApprovalAndLaterBlock, testManagedTargetsCannotBecomeAnotherProjectsImplementation, ...). Targeted JS `node --test test/chain-profile-isolation.test.mjs test/managed-chain.test.mjs test/managed-module.test.mjs test/review-flow.test.mjs`: 17 passed, 0 failed.

---

Judge's submission `d13f3111f936acf4302207d85a1264eb66881f8a2018ac18fd1e667779ae1fe6`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
