From 325e82cc98504bd78485d7ebc990621c73b1e664 Mon Sep 17 00:00:00 2001 From: Everest Date: Fri, 31 Jul 2026 06:58:51 +0800 Subject: [PATCH 1/5] fix(security): bind audit grants to origin space --- contracts/src/extensions/AuditGrant.sol | 28 ++++++++++++---- .../reference/AgentMemoryStateRegistry.sol | 12 ++++--- contracts/test/unit/AuditGrant.t.sol | 32 +++++++++++++++++++ 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/contracts/src/extensions/AuditGrant.sol b/contracts/src/extensions/AuditGrant.sol index d66f44f..cb8ca29 100644 --- a/contracts/src/extensions/AuditGrant.sol +++ b/contracts/src/extensions/AuditGrant.sol @@ -42,6 +42,7 @@ contract AuditGrant { error ZeroAuditor(); error GrantExists(); error UnknownGrant(); + error WrongSpace(bytes32 expected, bytes32 received); error NotAuditor(); error AlreadyAcknowledged(); error GrantIsRevoked(); @@ -65,6 +66,7 @@ contract AuditGrant { IAgentMemoryState public immutable registry; mapping(bytes32 grantId => Grant grant) private _grants; + mapping(bytes32 grantId => bytes32 spaceId) private _grantSpaces; event AuditGranted( bytes32 indexed spaceId, @@ -86,8 +88,7 @@ contract AuditGrant { } /// @notice Deterministic identifier for a grant. A given (space, auditor, range) can - /// be granted once; re-disclosing the same range to the same auditor under a - /// different witness set requires revoking first. + /// be granted once. Revocation preserves history and does not make the ID reusable. function deriveGrantId(bytes32 spaceId, address auditor, uint64 fromSequence, uint64 toSequence) public pure @@ -139,6 +140,7 @@ contract AuditGrant { acknowledgedAt: 0, revoked: false }); + _grantSpaces[grantId] = spaceId; emit AuditGranted(spaceId, auditor, grantId, fromSequence, toSequence, witnessSetRoot); } @@ -148,8 +150,7 @@ contract AuditGrant { /// points at one specific disclosure. See the contract-level note on what this /// does and does not prove. function acknowledge(bytes32 spaceId, bytes32 grantId, bytes32 computedRoot) external { - Grant storage g = _grants[grantId]; - if (g.witnessSetRoot == bytes32(0)) revert UnknownGrant(); + Grant storage g = _grantForSpace(spaceId, grantId); if (g.revoked) revert GrantIsRevoked(); if (msg.sender != g.auditor) revert NotAuditor(); if (g.acknowledgedAt != 0) revert AlreadyAcknowledged(); @@ -162,8 +163,7 @@ contract AuditGrant { /// @notice Withdraw a grant. History is preserved: prior events remain on chain. function revoke(bytes32 spaceId, bytes32 grantId) external { - Grant storage g = _grants[grantId]; - if (g.witnessSetRoot == bytes32(0)) revert UnknownGrant(); + Grant storage g = _grantForSpace(spaceId, grantId); if (g.revoked) revert GrantIsRevoked(); address controller = _controllerOf(spaceId); @@ -180,6 +180,22 @@ contract AuditGrant { return g; } + function spaceOf(bytes32 grantId) external view returns (bytes32 spaceId) { + spaceId = _grantSpaces[grantId]; + if (spaceId == bytes32(0)) revert UnknownGrant(); + } + + function _grantForSpace(bytes32 spaceId, bytes32 grantId) + private + view + returns (Grant storage g) + { + g = _grants[grantId]; + if (g.witnessSetRoot == bytes32(0)) revert UnknownGrant(); + bytes32 expectedSpaceId = _grantSpaces[grantId]; + if (spaceId != expectedSpaceId) revert WrongSpace(expectedSpaceId, spaceId); + } + function _controllerOf(bytes32 spaceId) internal view returns (address controller) { (controller,,) = registry.spaceAuthorization(spaceId); if (controller == address(0)) revert UnknownSpace(); diff --git a/contracts/src/reference/AgentMemoryStateRegistry.sol b/contracts/src/reference/AgentMemoryStateRegistry.sol index cea5303..6180174 100644 --- a/contracts/src/reference/AgentMemoryStateRegistry.sol +++ b/contracts/src/reference/AgentMemoryStateRegistry.sol @@ -294,14 +294,16 @@ contract AgentMemoryStateRegistry is IAgentMemoryState { /// delegated under EIP-7702 has non-empty code (`0xef0100 || delegate`), so /// branching on `code.length` alone would route every 7702 account into /// ERC-1271 and revert for the many delegates that implement no signature - /// policy. Trying ERC-1271 first still lets a delegate that *does* implement a - /// policy enforce it; the ECDSA fallback then covers plain EOAs and 7702 - /// accounts whose delegate is signature-agnostic. + /// policy. Trying ERC-1271 first accepts signatures approved by a delegate that + /// implements a policy; the ECDSA fallback then covers plain EOAs and 7702 + /// accounts whose delegate is signature-agnostic. It does not make that delegate + /// policy the exclusive authorization path. /// /// Security note: delegation does not revoke the underlying key, so for a 7702 /// account a valid ECDSA signature authorizes even when the delegate's policy - /// would have rejected it. That is a property of EIP-7702 itself, not of this - /// registry, but deployments relying on a delegate policy must account for it. + /// would have rejected it. Preserving that residual key path is an explicit + /// registry rule. Deployments requiring delegate-exclusive policy must use a + /// contract-account authorizer instead of a delegated EOA. function _requireAuthorization( address signer, bytes32 digest, diff --git a/contracts/test/unit/AuditGrant.t.sol b/contracts/test/unit/AuditGrant.t.sol index 58d0915..1e0b37d 100644 --- a/contracts/test/unit/AuditGrant.t.sol +++ b/contracts/test/unit/AuditGrant.t.sol @@ -11,18 +11,22 @@ contract AuditGrantTest is Test { AuditGrant internal extension; address internal controller = address(0xA11CE); + address internal otherController = address(0xB0B); address internal auditor = address(0xAD170); address internal stranger = address(0xBEEF); bytes32 internal constant SPACE_SALT = keccak256("space"); + bytes32 internal constant OTHER_SPACE_SALT = keccak256("other-space"); bytes32 internal constant PROFILE = keccak256("profile/text/v1"); bytes32 internal SPACE; + bytes32 internal OTHER_SPACE; bytes32[] internal transitionIds; function setUp() public { registry = new AgentMemoryStateRegistry(); SPACE = registry.deriveSpaceId(controller, SPACE_SALT); + OTHER_SPACE = registry.deriveSpaceId(otherController, OTHER_SPACE_SALT); extension = new AuditGrant(IAgentMemoryState(address(registry))); vm.startPrank(controller); @@ -44,6 +48,9 @@ contract AuditGrantTest is Test { prevRoot = nextRoot; } vm.stopPrank(); + + vm.prank(otherController); + registry.registerSpace(OTHER_SPACE, otherController, otherController, OTHER_SPACE_SALT, ""); } /// Fold the witness set the way an off-chain verifier would. @@ -73,6 +80,7 @@ contract AuditGrantTest is Test { assertEq(grantId, extension.deriveGrantId(SPACE, auditor, 1, 3)); AuditGrant.Grant memory g = extension.grantOf(grantId); + assertEq(extension.spaceOf(grantId), SPACE); assertEq(g.auditor, auditor); assertEq(g.witnessSetRoot, root); assertEq(g.acknowledgedAt, 0); @@ -167,6 +175,30 @@ contract AuditGrantTest is Test { extension.acknowledge(SPACE, grantId, root); } + function test_RevertWhen_AcknowledgementUsesAnotherSpace() public { + bytes32 root = _foldAll(3); + + vm.prank(controller); + bytes32 grantId = extension.grant(SPACE, auditor, 1, 3, root); + + vm.prank(auditor); + vm.expectRevert(abi.encodeWithSelector(AuditGrant.WrongSpace.selector, SPACE, OTHER_SPACE)); + extension.acknowledge(OTHER_SPACE, grantId, root); + } + + function test_RevertWhen_OtherSpaceControllerRevokesGrant() public { + bytes32 root = _foldAll(3); + + vm.prank(controller); + bytes32 grantId = extension.grant(SPACE, auditor, 1, 3, root); + + vm.prank(otherController); + vm.expectRevert(abi.encodeWithSelector(AuditGrant.WrongSpace.selector, SPACE, OTHER_SPACE)); + extension.revoke(OTHER_SPACE, grantId); + + assertFalse(extension.grantOf(grantId).revoked); + } + function test_RevertWhen_EmptyRootOrZeroAuditor() public { // Fold before the cheatcodes, for the same reason as above. bytes32 root = _foldAll(3); From 89f8b8e092dfd3d51e3ad94b32187cb81daa969c Mon Sep 17 00:00:00 2001 From: Everest Date: Fri, 31 Jul 2026 07:10:38 +0800 Subject: [PATCH 2/5] chore(security): prepare G4 external review gate --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + .github/workflows/security-rc.yml | 62 ++ CHANGELOG.md | 12 + SECURITY.md | 21 + audits/g4/EXTERNAL_REVIEW_REQUEST.md | 69 +++ audits/g4/INTERNAL_REVIEW.md | 88 +++ audits/g4/README.md | 71 +++ .../g4/external/external-review.template.json | 66 ++ audits/g4/internal/slither-summary.json | 68 +++ audits/g4/residual-risks.json | 288 +++++++++ audits/g4/scope.json | 116 ++++ docs/deployment.md | 12 +- docs/release-process.md | 21 +- docs/roadmap/README.md | 4 +- docs/security/residual-risks.md | 28 + docs/threat-model.md | 7 + erc/SUBMISSION.md | 4 +- package.json | 7 +- scripts/security-rc-gate.mjs | 563 ++++++++++++++++++ scripts/security-rc-gate.test.mjs | 238 ++++++++ 21 files changed, 1743 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/security-rc.yml create mode 100644 audits/g4/EXTERNAL_REVIEW_REQUEST.md create mode 100644 audits/g4/INTERNAL_REVIEW.md create mode 100644 audits/g4/README.md create mode 100644 audits/g4/external/external-review.template.json create mode 100644 audits/g4/internal/slither-summary.json create mode 100644 audits/g4/residual-risks.json create mode 100644 audits/g4/scope.json create mode 100644 docs/security/residual-risks.md create mode 100644 scripts/security-rc-gate.mjs create mode 100644 scripts/security-rc-gate.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 461654e..7fdaf17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - uses: pnpm/action-setup@v4 with: version: 11.7.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4b7545..6c3957c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - uses: pnpm/action-setup@v4 with: version: 11.7.0 diff --git a/.github/workflows/security-rc.yml b/.github/workflows/security-rc.yml new file mode 100644 index 0000000..0a2e9f1 --- /dev/null +++ b/.github/workflows/security-rc.yml @@ -0,0 +1,62 @@ +name: G4 security-ready RC + +on: + pull_request: + paths: + - ".github/workflows/security-rc.yml" + - "audits/g4/**" + - "contracts/src/**" + - "contracts/test/**" + - "contracts/script/Deploy.s.sol" + - "contracts/foundry.toml" + - "scripts/security-rc-gate.mjs" + - "scripts/security-rc-gate.test.mjs" + - "package.json" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-rc-${{ github.ref }} + cancel-in-progress: true + +jobs: + evidence: + name: G4 scope and evidence integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + version: 11.7.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.7.1 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Install forge-std + if: hashFiles('contracts/lib/forge-std/src/Test.sol') == '' + run: cd contracts && forge install foundry-rs/forge-std@v1.16.2 --no-git + - name: Validate frozen scope and residual risks + run: pnpm security:scope + - name: Test the security gate + run: pnpm security:test + - name: Run Solidity tests + run: cd contracts && forge test + - name: Validate external review when present + if: hashFiles('audits/g4/external/external-review.json') != '' + run: pnpm security:rc + - name: Record pending external review + if: hashFiles('audits/g4/external/external-review.json') == '' + run: | + echo "External Solidity review is still pending." >> "$GITHUB_STEP_SUMMARY" + echo "This workflow validates preparation only; release:check remains blocked." >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9a38e..4c4c13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable implementation and protocol changes are recorded here. ### Added +- G4 security-ready RC preparation: a commit- and SHA-256-pinned Solidity audit + scope, internal review record, canonical residual-risk register, external-review + request/template, evidence-integrity workflow, and an adversarially tested gate. + `security:scope` validates preparation; `security:rc` additionally requires a + real independent report and blocks release until one is accepted. - Clean-checkout conformance gate with separate Solidity, core TypeScript, and dependency-isolated TypeScript Golden Vector suites. Each run records the source commit/tree, vector SHA-256, tool versions, canonical outputs, suite @@ -30,6 +35,9 @@ All notable implementation and protocol changes are recorded here. ### Changed +- Release checks now require the complete G4 external-review gate. The 2026-07-26 + Sepolia `AuditGrant` address is explicitly deprecated and cannot be used as + evidence for the corrected candidate. - **Breaking (extension):** `DeletionAttestation.attest` now takes `bytes32 evidenceCommitment` instead of `bytes calldata evidence`. Callers must hash off chain and retain the preimage as a private witness. @@ -39,6 +47,10 @@ All notable implementation and protocol changes are recorded here. ### Fixed +- **High (extension):** `AuditGrant` now persists each grant's origin Space and + rejects cross-Space acknowledgement or revocation. Previously, the controller + of another registered Space could revoke a victim grant by supplying its own + `spaceId`, and an auditor could emit a misattributed acknowledgement event. - `DeletionAttestation` no longer publishes raw deletion evidence in transaction calldata, which contradicted architecture invariant 9 and the rule against exposing raw private payloads through a public wrapper. diff --git a/SECURITY.md b/SECURITY.md index c3fbfd4..7d19ef4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,27 @@ The contracts and packages are pre-release research software and have not been declared production ready. Do not use them to custody value or publicize private memory without an independent review of the exact deployed commit. +## G4 security-ready candidate + +The `G4-rc1` Solidity target is frozen at +`af5a75fb2db532fd5603554083d8895a825c2de2`. Its scope, internal review, and +remaining risks are recorded under [`audits/g4/`](audits/g4/). + +Current status: **external review pending; G4 not complete**. + +- `pnpm security:scope` verifies the source commit, tree, compiler settings, + per-file hashes, full scope, and residual-risk register. +- `pnpm security:test` exercises forged self-review, weakened scope, path + traversal, digest mismatch, missing risk mapping, and unresolved High findings. +- `pnpm security:rc` additionally requires a qualifying independent report and + fails until that report is accepted. +- `contracts/src/experimental/MemoryMarket.sol` is outside G4 and must not inherit + any security-ready label from the core review. + +The earlier Sepolia `AuditGrant` deployment predates the cross-Space authorization +fix and is deprecated. See +[`docs/security/residual-risks.md`](docs/security/residual-risks.md). + Report vulnerabilities privately through the repository's GitHub Security Advisory feature. Include affected versions, impact, reproduction steps, and a minimal test when possible. Do not put secrets, user memory, salts, keys, or diff --git a/audits/g4/EXTERNAL_REVIEW_REQUEST.md b/audits/g4/EXTERNAL_REVIEW_REQUEST.md new file mode 100644 index 0000000..eba86ea --- /dev/null +++ b/audits/g4/EXTERNAL_REVIEW_REQUEST.md @@ -0,0 +1,69 @@ +# External Solidity Review Request: ERC-8350 G4-rc1 + +Status: **open; external review not yet complete** + +ERC-8350 needs one independent Solidity security review before the reference +contracts can be called a security-ready release candidate. This request is open +to an established auditor, security researcher, or independent Solidity team +with no maintainer role in AwareLiquid/ERC-8350. + +## Frozen target + +- Repository: https://github.com/AwareLiquid/ERC-8350 +- Source commit: `af5a75fb2db532fd5603554083d8895a825c2de2` +- Source tree: `f8d66277123391ea54de3f53842256e17e2a497e` +- Immutable source ref: `refs/tags/audit/g4-rc1-source` +- Scope manifest: `audits/g4/scope.json` +- Scope SHA-256: `c9db7d27957c86386d8842687a2d009c0ca03bc864307e88cfed0ee25f3ae9bf` +- Compiler: Solidity `0.8.24`, optimizer 200 runs, Cancun EVM +- Solidity source lines: 895 across libraries, interfaces, core, three + extensions, and the deployment script (including comments) + +`contracts/src/experimental/MemoryMarket.sol` is explicitly excluded. It transfers +value, has a different trust model, and is not deployed by `Deploy.s.sol`. + +## Priority questions + +1. Can any account register, take over, rotate, or append to another Memory Space? +2. Do EIP-712, sequence, root, nonce, and Registry-domain bindings prevent mutation + and replay in every authorization path? +3. Are EOA, ERC-1271, malformed return, revert, and EIP-7702 fallback behaviors safe + and accurately represented? +4. Can any extension act against or emit authoritative-looking events for the wrong + Space, controller, transition, grant, or auditor? +5. Can public calldata or events expose raw memory, locators, salts, evidence, or + otherwise undermine the stated commitment model? +6. Do compiler settings, deployment code, sentinels, casts, and external calls + introduce unexpected behavior? + +Please specifically assess `G4-R-003`, the normative EIP-7702-to-ECDSA fallback, +and verify the fix for `G4-INT-001`, the former cross-Space `AuditGrant` revocation. + +## Reproduce + +From the branch containing this audit packet: + +```bash +pnpm install --frozen-lockfile +pnpm security:scope +pnpm check +cd contracts +forge test -vv +``` + +## Required evidence + +1. Publish a report at a stable public URL in a repository controlled by the + reviewer or the review organization. +2. Identify the exact source commit and scope-manifest SHA-256 above. +3. Disclose independence, compensation, prior project work, and other conflicts. +4. Give every finding a stable ID, severity, status, rationale, and verification. +5. Leave no unresolved Critical or High finding. +6. Map every accepted Medium/Low/Informational finding into + `audits/g4/residual-risks.json`. +7. Submit the report, its SHA-256, and a completed copy of + `external/external-review.template.json` in a pull request. + +The machine gate validates scope and evidence integrity; it does not decide whether +a reviewer is competent. Maintainers must still assess reviewer identity, method, +conflicts, and finding quality before accepting the report. diff --git a/audits/g4/INTERNAL_REVIEW.md b/audits/g4/INTERNAL_REVIEW.md new file mode 100644 index 0000000..77375e9 --- /dev/null +++ b/audits/g4/INTERNAL_REVIEW.md @@ -0,0 +1,88 @@ +# G4 Internal Solidity Review + +Status: **complete as internal preparation; not an external audit** + +- Candidate: `G4-rc1` +- Frozen source commit: `af5a75fb2db532fd5603554083d8895a825c2de2` +- Review date: 2026-07-31 +- Solidity: `0.8.24` +- Foundry: `1.7.1` +- Slither: `0.11.6` (102 detectors) + +This review prepares a stable target for an independent reviewer. It cannot satisfy +`External review complete` because the work was performed inside the project. + +## Method + +1. Manually traced registration, controller rotation, transition authorization, + state advancement, lookup behavior, and extension-to-Registry trust boundaries. +2. Reviewed EIP-712 field coverage, replay domains, ECDSA malleability handling, + ERC-1271 malformed/revert behavior, and the EIP-7702 fallback rule. +3. Reviewed all formal extensions for cross-Space authorization, public-data + disclosure, sentinel values, stale controller behavior, and event attribution. +4. Ran Slither against the formal Solidity scope with experimental, test, and + script paths filtered. +5. Ran all unit, Golden Vector, demo, and invariant tests. + +## Actionable finding + +### G4-INT-001: Cross-Space AuditGrant revocation and event misattribution + +- Severity: **High** +- Status: **Fixed** +- Affected revision: all `AuditGrant` versions before source commit `af5a75f` +- Fixed revision: `af5a75fb2db532fd5603554083d8895a825c2de2` + +`AuditGrant` derived a grant ID from its origin Space but did not persist that +Space. `revoke(spaceId, grantId)` authorized the controller supplied by the caller, +not the controller of the grant's origin Space. A controller of any other registered +Space could therefore revoke a victim grant. An auditor could also acknowledge a +grant while supplying another Space, producing a misattributed event. + +The fix stores the origin Space for every grant and rejects `acknowledge` and +`revoke` when the supplied Space differs. Regression tests cover both cross-Space +paths and assert the persisted origin through `spaceOf`. + +The Sepolia `AuditGrant` at +`0x20145Ab83958CFB321221e8a8C68181C818241B2` predates the fix and is deprecated. +It must not be used as evidence that the corrected extension is security-ready. + +## Static-analysis disposition + +Slither returned six detector results and no actionable issue after manual +triage. The machine-readable record is +[`internal/slither-summary.json`](internal/slither-summary.json). + +| ID | Detector | Reported impact | Disposition | +|---|---|---:|---| +| `G4-SLITHER-001` | strict equality on the non-zero grant sentinel | Medium | False positive | +| `G4-SLITHER-002` | unused Registry head fields | Medium | Intentional | +| `G4-SLITHER-003` | unused authorization tuple fields | Medium | Intentional | +| `G4-SLITHER-004` | timestamp dataflow on a root comparison | Low | False positive | +| `G4-SLITHER-005` | bounded ECDSA calldata assembly | Informational | Intentional | +| `G4-SLITHER-006` | ERC-1271 low-level `STATICCALL` | Informational | Required behavior | + +## Reproduce + +```bash +cd contracts +forge fmt --check +forge test -vv + +slither . \ + --compile-force-framework foundry \ + --exclude-dependencies \ + --filter-paths 'src/experimental|test|script' +``` + +Observed Foundry result: **48 passed, 0 failed**, including one invariant suite +with 256 runs and 128,000 handler calls. + +## Limitations + +- This is not independent and does not complete G4. +- Slither findings are heuristics; a clean disposition is not proof of safety. +- `MemoryMarket` is experimental and explicitly outside this review. +- Off-chain encryption, salt generation, witness availability, wallet policy, + and deployment operations remain profile or deployment responsibilities. +- Any scoped source or compiler-setting change invalidates the external review. diff --git a/audits/g4/README.md b/audits/g4/README.md new file mode 100644 index 0000000..75ce9ca --- /dev/null +++ b/audits/g4/README.md @@ -0,0 +1,71 @@ +# G4 Security-Ready Release Candidate + +Current status: **PREPARED, NOT COMPLETE** + +The source target, internal review, risk register, and evidence gate are ready. +The independent external Solidity review is still open. Until a qualifying report +is accepted, `pnpm security:rc` and `pnpm release:check` fail by design, and no +artifact from this branch may be described as security-ready or production-ready. + +## Gate status + +| Requirement | Evidence | Status | +|---|---|---:| +| Frozen Solidity target | [`scope.json`](scope.json), commit `af5a75f` | Pass | +| Internal review and regression fix | [`INTERNAL_REVIEW.md`](INTERNAL_REVIEW.md) | Pass | +| Remaining risks explicitly registered | [`residual-risks.json`](residual-risks.json) | Pass | +| Scope/evidence gate has adversarial tests | `scripts/security-rc-gate*.mjs` | Pass | +| Independent external Solidity report | [`EXTERNAL_REVIEW_REQUEST.md`](EXTERNAL_REVIEW_REQUEST.md) | **Pending** | +| No unresolved Critical/High external findings | Requires accepted external report | **Pending** | +| G4 full gate | `pnpm security:rc` | **Blocked by pending review** | + +## Frozen source + +- Commit: `af5a75fb2db532fd5603554083d8895a825c2de2` +- Tree: `f8d66277123391ea54de3f53842256e17e2a497e` +- Immutable source ref: `refs/tags/audit/g4-rc1-source` +- Scope SHA-256: + `c9db7d27957c86386d8842687a2d009c0ca03bc864307e88cfed0ee25f3ae9bf` +- Residual-risk SHA-256: + `307a52eb0bf512aea25565bf604d92fc6b1b2d64eb910595568a5f0c04aa6019` + +Any change to a scoped file or compiler setting invalidates these hashes and +requires a new candidate plus external review of the changed target. + +## Commands + +```bash +# Passes now: validates exact source, scope, and risk registration. +pnpm security:scope + +# Passes now: attacks the gate's trust assumptions and failure paths. +pnpm security:test + +# Fails until audits/g4/external/external-review.json and its report are accepted. +pnpm security:rc +``` + +The full gate requires an external reviewer-controlled evidence repository, a +public report with pinned bytes, a full-commit attestation, exact scope and risk +digests, an assessment of every accepted residual risk, dispositions for every +finding, and zero unresolved Critical or High findings. + +## Completion procedure + +1. Confirm the reviewer has no project maintainer role and evaluate expertise, + conflicts, compensation, and prior involvement. +2. Give the reviewer the frozen commit and + [`EXTERNAL_REVIEW_REQUEST.md`](EXTERNAL_REVIEW_REQUEST.md). +3. Resolve findings. Any scoped source change creates a new frozen commit and must + be included in the reviewer's final verification. +4. Have the reviewer publish the final report and a commit attestation in their + own evidence repository. +5. Add the exact report bytes under `external/reports/` and complete + `external/external-review.json` from the provided template. +6. Map accepted non-Critical/High findings to `residual-risks.json`. +7. Run `pnpm security:rc`, `pnpm check`, and the clean-checkout conformance gate. +8. Obtain maintainer approval of reviewer identity and report quality, then mark + G4 complete and create the RC tag. + +The script checks evidence integrity. It cannot establish reviewer competence or +independence on its own; those remain explicit human approval duties. diff --git a/audits/g4/external/external-review.template.json b/audits/g4/external/external-review.template.json new file mode 100644 index 0000000..6ff285d --- /dev/null +++ b/audits/g4/external/external-review.template.json @@ -0,0 +1,66 @@ +{ + "schema": "erc-8350/external-solidity-review/v1", + "candidate": "G4-rc1", + "reviewer": { + "name": "", + "organization": "", + "githubOwner": "", + "evidenceRepository": "https://github.com//", + "independentFromProject": true, + "conflictDisclosure": "" + }, + "scope": { + "repository": "https://github.com/AwareLiquid/ERC-8350", + "commit": "af5a75fb2db532fd5603554083d8895a825c2de2", + "tree": "f8d66277123391ea54de3f53842256e17e2a497e", + "manifestPath": "audits/g4/scope.json", + "manifestSha256": "c9db7d27957c86386d8842687a2d009c0ca03bc864307e88cfed0ee25f3ae9bf", + "riskRegisterPath": "audits/g4/residual-risks.json", + "riskRegisterSha256": "307a52eb0bf512aea25565bf604d92fc6b1b2d64eb910595568a5f0c04aa6019" + }, + "report": { + "path": "reports/", + "url": "https://", + "sha256": "<64 lowercase hex characters>", + "publishedAt": "" + }, + "methodology": { + "manualReview": true, + "tools": [ + "" + ], + "testCommands": [ + "cd contracts && forge test -vv" + ] + }, + "conclusion": { + "status": "pass", + "noUnresolvedCriticalOrHigh": true, + "unresolved": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "informational": 0 + } + }, + "reviewedResidualRiskIds": [ + "G4-R-001", + "G4-R-002", + "G4-R-003", + "G4-R-004", + "G4-R-005", + "G4-R-006", + "G4-R-007", + "G4-R-008", + "G4-R-009", + "G4-R-010", + "G4-R-011" + ], + "findings": [], + "attestation": { + "statement": "I independently reviewed the exact G4 scope and the identified report is my work.", + "url": "https://github.com///commit/", + "signedAt": "" + } +} diff --git a/audits/g4/internal/slither-summary.json b/audits/g4/internal/slither-summary.json new file mode 100644 index 0000000..0e0e0c2 --- /dev/null +++ b/audits/g4/internal/slither-summary.json @@ -0,0 +1,68 @@ +{ + "schema": "erc-8350/internal-static-analysis/v1", + "candidate": "G4-rc1", + "sourceCommit": "af5a75fb2db532fd5603554083d8895a825c2de2", + "recordedAt": "2026-07-31", + "tool": { + "name": "Slither", + "version": "0.11.6", + "detectorsRun": 102, + "command": "slither . --compile-force-framework foundry --exclude-dependencies --filter-paths 'src/experimental|test|script' --json " + }, + "result": { + "detectorResults": 6, + "unresolvedCritical": 0, + "unresolvedHigh": 0, + "actionable": 0 + }, + "dispositions": [ + { + "id": "G4-SLITHER-001", + "check": "incorrect-equality", + "reportedImpact": "medium", + "status": "false-positive", + "location": "contracts/src/extensions/AuditGrant.sol:179", + "rationale": "witnessSetRoot == 0 is an explicit existence sentinel. grant() rejects EmptyWitnessRoot before storage, so a valid grant cannot collide with the sentinel." + }, + { + "id": "G4-SLITHER-002", + "check": "unused-return", + "reportedImpact": "medium", + "status": "accepted", + "location": "contracts/src/extensions/AuditGrant.sol:128", + "rationale": "grant() intentionally reads only headSequence; transitionId and stateRoot are unrelated to range bounds." + }, + { + "id": "G4-SLITHER-003", + "check": "unused-return", + "reportedImpact": "medium", + "status": "accepted", + "location": "contracts/src/extensions/AuditGrant.sol:200", + "rationale": "_controllerOf() intentionally reads only controller from the Registry authorization tuple." + }, + { + "id": "G4-SLITHER-004", + "check": "timestamp", + "reportedImpact": "low", + "status": "false-positive", + "location": "contracts/src/extensions/AuditGrant.sol:179", + "rationale": "The detector traces the Grant struct that also contains timestamps, but the reported comparison is against witnessSetRoot and does not depend on block.timestamp." + }, + { + "id": "G4-SLITHER-005", + "check": "assembly", + "reportedImpact": "informational", + "status": "accepted", + "location": "contracts/src/ECDSA.sol:18", + "rationale": "The bounded assembly reads r, s, and v from a signature whose length was checked as exactly 65 bytes. Low-s and non-zero signer checks follow." + }, + { + "id": "G4-SLITHER-006", + "check": "low-level-calls", + "reportedImpact": "informational", + "status": "accepted", + "location": "contracts/src/reference/AgentMemoryStateRegistry.sol:330", + "rationale": "STATICCALL is required for ERC-1271. The result must succeed, contain at least 32 bytes, and decode to the exact magic value before authorization." + } + ] +} diff --git a/audits/g4/residual-risks.json b/audits/g4/residual-risks.json new file mode 100644 index 0000000..a5d975d --- /dev/null +++ b/audits/g4/residual-risks.json @@ -0,0 +1,288 @@ +{ + "schema": "erc-8350/residual-risk-register/v1", + "candidate": "G4-rc1", + "asOf": "2026-07-31", + "policy": { + "unresolvedCriticalOrHighAllowed": false, + "acceptedSeverities": [ + "medium", + "low", + "informational" + ], + "reviewRequiredAfterSourceChange": true + }, + "risks": [ + { + "id": "G4-R-001", + "title": "Controller compromise or irreversible rotation error", + "severity": "medium", + "likelihood": "low", + "status": "accepted", + "affectedComponents": [ + "AgentMemoryStateRegistry", + "all extensions" + ], + "description": "A controller can replace both controller and authorizer. The one-step rotation has no on-chain acceptance handshake, so compromise or an incorrect destination can transfer control or make recovery impossible.", + "existingControls": [ + "EIP-712 domain separation", + "per-Space configNonce", + "non-zero destination checks", + "controller-only authorization" + ], + "deploymentRequirements": [ + "Use a reviewed multisignature or policy contract for valuable Spaces.", + "Simulate and independently verify both replacement addresses before rotation.", + "Document an operational recovery and key-compromise procedure." + ], + "owner": "Space deployer", + "reviewTrigger": "Controller model, rotation flow, or recovery policy changes" + }, + { + "id": "G4-R-002", + "title": "Compromised or overly permissive transition authorizer", + "severity": "medium", + "likelihood": "low", + "status": "accepted", + "affectedComponents": [ + "AgentMemoryStateRegistry" + ], + "description": "The Registry proves authorization and ordering, not that an authorized memory transition is true or desirable. A compromised authorizer can append valid-looking state.", + "existingControls": [ + "controller-controlled authorizer rotation", + "exact sequence and previous-root checks", + "EIP-712 signed transition fields" + ], + "deploymentRequirements": [ + "Separate controller and authorizer duties.", + "Use threshold, rate-limited, or session-scoped policy for valuable Spaces.", + "Monitor TransitionCommitted events and define incident response." + ], + "owner": "Space deployer", + "reviewTrigger": "Authorizer implementation or policy changes" + }, + { + "id": "G4-R-003", + "title": "EIP-7702 fallback preserves the underlying ECDSA authorization path", + "severity": "medium", + "likelihood": "medium", + "status": "accepted", + "affectedComponents": [ + "AgentMemoryStateRegistry" + ], + "description": "ERC-8350 requires trying ERC-1271 before canonical ECDSA. For a delegated EOA, a valid underlying-key signature is therefore accepted even when delegate code would reject the same action. Delegate policy is not exclusive.", + "existingControls": [ + "behavior is explicit in the ERC and reference implementation", + "ERC-1271 is attempted first", + "canonical low-s ECDSA validation" + ], + "deploymentRequirements": [ + "Do not use a delegated EOA when delegate-exclusive policy is a security requirement.", + "Use a contract-account authorizer address for multisignature or policy-only control.", + "Treat the delegated EOA key as fully privileged." + ], + "owner": "Space deployer", + "reviewTrigger": "ERC-8350 signature-validation rules or EIP-7702 semantics change" + }, + { + "id": "G4-R-004", + "title": "Relayer censorship and delay", + "severity": "low", + "likelihood": "medium", + "status": "accepted", + "affectedComponents": [ + "AgentMemoryStateRegistry" + ], + "description": "A relayer cannot alter signed Delta fields but can withhold, reorder pending submissions, or delay inclusion.", + "existingControls": [ + "all ExperienceDelta fields are signed", + "strict sequence and prior-root validation" + ], + "deploymentRequirements": [ + "Maintain a direct-submission path or multiple relayers.", + "Alert on stalled outbox entries and sequence gaps." + ], + "owner": "Application operator", + "reviewTrigger": "Relayer architecture changes" + }, + { + "id": "G4-R-005", + "title": "Metadata leakage and weak commitment secrets", + "severity": "medium", + "likelihood": "medium", + "status": "accepted", + "affectedComponents": [ + "AgentMemoryStateRegistry", + "PrivateCommitment" + ], + "description": "Timing, sequence, profile identifiers, and commitment equality are public. Low-entropy payloads remain dictionary-attackable when salts are weak or reused.", + "existingControls": [ + "domain-separated baseline commitments", + "raw payloads and locators are absent from core calldata", + "locatorCommitment is signed" + ], + "deploymentRequirements": [ + "Use fresh cryptographically random 32-byte salts for every commitment domain.", + "Encrypt sensitive witnesses before storage and rotate encryption nonces and keys.", + "Use opaque per-Space profile identifiers when kind privacy is required." + ], + "owner": "Profile and application implementer", + "reviewTrigger": "Commitment profile or public event schema changes" + }, + { + "id": "G4-R-006", + "title": "Witness and locator unavailability", + "severity": "medium", + "likelihood": "medium", + "status": "accepted", + "affectedComponents": [ + "off-chain witness storage", + "AuditGrant" + ], + "description": "A valid on-chain commitment can become permanently unopenable if encrypted witnesses, salts, keys, or locators are lost. The contracts do not provide data availability.", + "existingControls": [ + "locator commitment prevents relayer substitution", + "AuditGrant can bind an out-of-band disclosure set" + ], + "deploymentRequirements": [ + "Use redundant encrypted storage and tested restoration procedures.", + "Define retention, key escrow, and availability service levels per profile." + ], + "owner": "Application operator", + "reviewTrigger": "Storage or key-management architecture changes" + }, + { + "id": "G4-R-007", + "title": "Fork replay before operational finality", + "severity": "low", + "likelihood": "low", + "status": "accepted", + "affectedComponents": [ + "AgentMemoryStateRegistry" + ], + "description": "The EIP-712 domain binds chain ID and Registry address, but two branches of the same chain can temporarily share both values.", + "existingControls": [ + "chain ID and verifying-contract domain binding", + "linear state-root chain" + ], + "deploymentRequirements": [ + "Apply an application-specific confirmation/finality policy.", + "Reconcile transition heads after a material chain reorganization." + ], + "owner": "Application operator", + "reviewTrigger": "Target chain or finality assumptions change" + }, + { + "id": "G4-R-008", + "title": "External smart-account validation defects or gas griefing", + "severity": "medium", + "likelihood": "low", + "status": "accepted", + "affectedComponents": [ + "AgentMemoryStateRegistry", + "configured ERC-1271 accounts" + ], + "description": "Authorization depends on external ERC-1271 code. A wallet can be buggy, mutable, unexpectedly permissive, or consume enough gas to make operations unreliable.", + "existingControls": [ + "STATICCALL", + "exact ERC-1271 magic-value check", + "state mutation occurs after authorization" + ], + "deploymentRequirements": [ + "Use a reviewed wallet implementation and pin its upgrade authority.", + "Test valid, invalid, reverting, and malformed-return behavior before registration." + ], + "owner": "Space deployer", + "reviewTrigger": "Wallet implementation or upgrade authority changes" + }, + { + "id": "G4-R-009", + "title": "Wrappers and proxies can change immutable reference semantics", + "severity": "medium", + "likelihood": "low", + "status": "accepted", + "affectedComponents": [ + "deployment architecture" + ], + "description": "The reference Registry is non-upgradeable. A third-party proxy, wrapper, or modified deployment can introduce admin powers or behavior not covered by the review.", + "existingControls": [ + "dependency-free immutable reference implementation", + "verifying-contract address in the EIP-712 domain" + ], + "deploymentRequirements": [ + "Publish and monitor deployed bytecode hashes.", + "Treat proxy or wrapper deployments as a new audit scope." + ], + "owner": "Deployment operator", + "reviewTrigger": "Any bytecode, proxy, wrapper, or compiler-setting change" + }, + { + "id": "G4-R-010", + "title": "Public extension metadata can disclose sensitive context", + "severity": "low", + "likelihood": "medium", + "status": "accepted", + "affectedComponents": [ + "SpaceDescriptor", + "AuditGrant", + "DeletionAttestation" + ], + "description": "Descriptor URIs, audit participants and ranges, acknowledgement timing, and deletion-attestation timing are public by construction.", + "existingControls": [ + "extensions are opt-in", + "AuditGrant stores witness roots rather than witnesses", + "DeletionAttestation stores a commitment rather than evidence" + ], + "deploymentRequirements": [ + "Never place secrets, salts, keys, private locators, or raw evidence in extension calldata.", + "Use the core without metadata extensions when relationship privacy is required." + ], + "owner": "Application operator", + "reviewTrigger": "Extension event or calldata schema changes" + }, + { + "id": "G4-R-011", + "title": "Attestations and audit acknowledgements are limited claims", + "severity": "low", + "likelihood": "medium", + "status": "accepted", + "affectedComponents": [ + "AuditGrant", + "DeletionAttestation" + ], + "description": "AuditGrant does not prove witness truth, auditor competence, or receipt. DeletionAttestation does not prove universal erasure and allows one immutable evidence commitment per transition.", + "existingControls": [ + "limitations documented in contract NatSpec", + "auditor-only acknowledgement", + "controller-only deletion attestation" + ], + "deploymentRequirements": [ + "Present these records as scoped attestations, never as truth or universal deletion proofs.", + "Define off-chain auditor identity, evidence, and correction policy." + ], + "owner": "Application and verifier", + "reviewTrigger": "Claim semantics or user-facing representations change" + }, + { + "id": "G4-R-012", + "title": "Legacy Sepolia AuditGrant predates the cross-Space fix", + "severity": "high", + "likelihood": "medium", + "status": "mitigated", + "affectedComponents": [ + "AuditGrant at 0x20145Ab83958CFB321221e8a8C68181C818241B2 on Sepolia" + ], + "description": "The 2026-07-26 test deployment does not bind grant IDs to their origin Space during revoke and acknowledge. A controller of another registered Space can revoke a grant, and an auditor can misattribute its acknowledgement event.", + "existingControls": [ + "fixed in source commit af5a75fb2db532fd5603554083d8895a825c2de2", + "cross-Space regression tests" + ], + "deploymentRequirements": [ + "Treat the legacy address as deprecated test evidence only.", + "Do not create or rely on grants at the legacy address.", + "Deploy the reviewed candidate to a new address only after G4 passes." + ], + "owner": "Maintainers", + "reviewTrigger": "New AuditGrant deployment or legacy-address documentation changes" + } + ] +} diff --git a/audits/g4/scope.json b/audits/g4/scope.json new file mode 100644 index 0000000..b301788 --- /dev/null +++ b/audits/g4/scope.json @@ -0,0 +1,116 @@ +{ + "schema": "erc-8350/security-review-scope/v1", + "candidate": "G4-rc1", + "repository": "https://github.com/AwareLiquid/ERC-8350", + "source": { + "commit": "af5a75fb2db532fd5603554083d8895a825c2de2", + "tree": "f8d66277123391ea54de3f53842256e17e2a497e", + "ref": "refs/tags/audit/g4-rc1-source" + }, + "toolchain": { + "solidity": "0.8.24", + "foundry": "1.7.1", + "optimizer": true, + "optimizerRuns": 200, + "evmVersion": "cancun" + }, + "acceptanceCriteria": { + "independentReviewerRequired": true, + "externalRepositoryRequired": true, + "exactSourceCommitRequired": true, + "sourceHashesRequired": true, + "publicReportRequired": true, + "reportDigestRequired": true, + "noUnresolvedCriticalOrHigh": true, + "allOtherFindingsDispositioned": true, + "residualRiskMappingRequired": true + }, + "inScope": [ + { + "path": "contracts/src/ECDSA.sol", + "kind": "library", + "sha256": "fdf13dd3c50b06ec54f0260dfd10f3a8c1349380ae878d73966981994cbb632d" + }, + { + "path": "contracts/src/interfaces/IAgentMemoryState.sol", + "kind": "interface", + "sha256": "b0fe1be9c7c958004eb500899d2995532a9b3c955cead4c1ac3a829d5610a46a" + }, + { + "path": "contracts/src/interfaces/IERC1271.sol", + "kind": "interface", + "sha256": "2c384028dc25b3e2968254332d2f3d8ca499f868f50387a5ee9efb292e5fa8f6" + }, + { + "path": "contracts/src/reference/AgentMemoryStateRegistry.sol", + "kind": "runtime", + "sha256": "d0f1493f0c5a7eac5a33ebb1320d21fcd1d7799ada3f18647da76af569762ae6" + }, + { + "path": "contracts/src/reference/PrivateCommitment.sol", + "kind": "library", + "sha256": "af5c1e5bb9a1c6fb11655367084bc1079deabe30cb961b6b73932a0d2b13deca" + }, + { + "path": "contracts/src/extensions/AuditGrant.sol", + "kind": "runtime-extension", + "sha256": "33aac29f9b1ed3604951667256e096de8e289cc09754eb38dc0bf02a4307f513" + }, + { + "path": "contracts/src/extensions/DeletionAttestation.sol", + "kind": "runtime-extension", + "sha256": "6d1f48ed11851a0030eb064f39d6c8a81bd6e48c20d97560c9c44ce28d37b94f" + }, + { + "path": "contracts/src/extensions/SpaceDescriptor.sol", + "kind": "runtime-extension", + "sha256": "91dd7c31db67d0fa37a3b0af604b395aca14d50398e7aff452fdda711f0d7d30" + }, + { + "path": "contracts/script/Deploy.s.sol", + "kind": "deployment", + "sha256": "6351686f1a47fcef0505e3ea1e07c70c28826c0d580a7e8e73864f31468f8a37" + }, + { + "path": "contracts/foundry.toml", + "kind": "build-configuration", + "sha256": "bfaa78533173670143902f4335f1c3e86646717c2122848e7f246074e50430a8" + } + ], + "supportingEvidence": [ + { + "path": "test-vectors/v1.json", + "sha256": "09b865b082b198606b0b005eec0df995d277f76f1e6b06550907472a9dcd2872" + }, + { + "path": "contracts/test/unit/AuditGrant.t.sol", + "sha256": "c409d590f94df5bd426a4980c957fe6b3656ce0a77598fc0afe3be19e3e4904b" + }, + { + "path": "contracts/test/unit/AgentMemoryStateRegistry.t.sol", + "sha256": "09a8d658f1e24b598235ae808b59d7ec3086e38a8d68c06f4878ad206ed8a474" + }, + { + "path": "contracts/test/invariant/StateMachineInvariant.t.sol", + "sha256": "e20069adbc3ca4c5da375dffadb8e0e78d241497b661277a6afd109f642c447d" + } + ], + "excluded": [ + { + "path": "contracts/src/experimental/MemoryMarket.sol", + "reason": "Experimental value-transfer contract with a separate trust model; it is not deployed by Deploy.s.sol and is not part of G4." + }, + { + "path": "packages/", + "reason": "TypeScript products, adapters, and SDK code are covered by conformance testing, not this Solidity review." + }, + { + "path": "implementations/", + "reason": "Independent TypeScript implementation; covered by the Golden Vector conformance gate." + }, + { + "path": "witness-storage-and-encryption-profiles", + "reason": "Off-chain key management, storage availability, and profile-specific cryptography are deployment responsibilities." + } + ] +} diff --git a/docs/deployment.md b/docs/deployment.md index 518d853..3f8231b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -97,12 +97,17 @@ registry, and the EIP-712 domain already distinguishes them. ### Sepolia (chainId 11155111) — 2026-07-26 +> Security notice (2026-07-31): these are pre-audit test deployments. The +> `AuditGrant` address below predates the fix that binds each grant to its origin +> Space. It is deprecated and MUST NOT be used for new grants or treated as G4 +> evidence. The corrected candidate has not been redeployed. + | Contract | Address | Source | |---|---|---| | `AgentMemoryStateRegistry` | `0xDdf21937ba80b5fF973610877A0955b320C91241` | [verified](https://sepolia.etherscan.io/address/0xDdf21937ba80b5fF973610877A0955b320C91241#code) | | `DeletionAttestation` | `0x97cc9b019A089bf7b821d47134020896f9259cc0` | [verified](https://sepolia.etherscan.io/address/0x97cc9b019A089bf7b821d47134020896f9259cc0#code) | | `SpaceDescriptor` | `0x7745e2dDC30e75E1D7B7fBAf4616Fc0F54e571F5` | [verified](https://sepolia.etherscan.io/address/0x7745e2dDC30e75E1D7B7fBAf4616Fc0F54e571F5#code) | -| `AuditGrant` | `0x20145Ab83958CFB321221e8a8C68181C818241B2` | [verified](https://sepolia.etherscan.io/address/0x20145Ab83958CFB321221e8a8C68181C818241B2#code) | +| `AuditGrant` (**deprecated**) | `0x20145Ab83958CFB321221e8a8C68181C818241B2` | [verified](https://sepolia.etherscan.io/address/0x20145Ab83958CFB321221e8a8C68181C818241B2#code) | Domain separator (binds signatures to this chain + registry): `0x4f36e5833f961f835ccd6ac49572ed105e4cdbf865f8904fe3d020724148b6ac` @@ -143,3 +148,8 @@ Verification is what turns "the bytecode at this address behaves as specified" f claim into something a reader can check: the published source recompiles to the deployed bytecode under the stated settings, so the golden-vector agreement above is auditable rather than asserted. + +The `AuditGrant` defect is fixed in frozen G4 source commit +`af5a75fb2db532fd5603554083d8895a825c2de2`; a replacement deployment is blocked +until the independent G4 Solidity review is complete. The deprecated instance above +remains source-verified so the defect itself is auditable. diff --git a/docs/release-process.md b/docs/release-process.md index 3cb260a..864e7b9 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -22,10 +22,27 @@ the draft an accepted ERC and do not imply that contracts are production ready. 5. Verify the clean-checkout record shows the Solidity and both TypeScript implementations matched the same `test-vectors/v1.json` SHA-256. 6. Review storage layout, ABI changes, threat model, and migration notes. -7. Create a signed `v*` tag. The release workflow builds package tarballs, +7. Run `pnpm security:rc`. For an RC this requires a report by an independent + reviewer against the exact commit and scope digest, all findings dispositioned, + every accepted finding mapped to the residual-risk register, and no unresolved + Critical or High finding. +8. Manually verify reviewer identity, expertise, conflict disclosure, report + quality, and the public attestation. Passing JSON validation alone is not an + audit. +9. Create a signed `v*` tag. The release workflow builds package tarballs, contract sources, and SHA-256 checksums without publishing automatically. -8. Attach audit and testnet deployment references before labeling an artifact +10. Attach audit and testnet deployment references before labeling an artifact production ready. Package publication and contract deployment require a separate human approval. Never deploy by reusing development keys or unrevealed commitment salts. + +## G4 evidence + +The live status and completion procedure are in +[`audits/g4/README.md`](../audits/g4/README.md). `pnpm security:scope` is a +preparation check and may pass before an external audit. Only +`pnpm security:rc` is the full G4 gate, and `release:check` invokes it. + +Any change to an in-scope Solidity file, `Deploy.s.sol`, or the pinned compiler +configuration invalidates the current report and requires a new frozen candidate. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 0aa3941..a087d55 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -14,7 +14,7 @@ items graduate only by moving up a label. |---|---| | Core registry: linear, authorized, private state transitions | Sepolia `0xDdf21937ba80b5fF973610877A0955b320C91241`, reproduces `test-vectors/v1.json` byte-for-byte | | Behavioral recording as commitments — actions enter `deltaCommitment` payloads (`TOOL_TRACE` / `EPISODIC` profiles), ordering pinned by `sequence` | Core + adapter vocabulary | -| Selective, provably-complete audit disclosure — `AuditGrant` binds a `[from,to]` range to one `witnessSetRoot`; withholding or substituting any witness changes the root | Sepolia `0x20145Ab83958CFB321221e8a8C68181C818241B2` | +| Selective, provably-complete audit disclosure — `AuditGrant` binds a `[from,to]` range to one `witnessSetRoot`; withholding or substituting any witness changes the root | Corrected source frozen at `af5a75f`; the older Sepolia `0x20145A…41B2` deployment has a cross-Space authorization defect and is deprecated | | **First production adopter** — Awareness (awareness.market) anchors real knowledge-card state from its local daemon: pull-based snapshot diff → outbox → CLI-signed broadcast, witnesses never leaving the user machine | Live on Sepolia 2026-07-28: Space `0xfdd18b37…`, tx `0x98f1cf76…`, head verified byte-for-byte against local precomputation by three independent paths | | **Independent reproduction evidence (R-002)** — externally maintained, commit-pinned Python implementations replay the public state machine and recompute a cross-ERC composition without importing this repository | ERC8312 Sepolia replay + Invinoveritas saved-artifact recompute; both clean-checkout suites pass, see `docs/interop/external-reproduction.md` | | Opt-in space description | `SpaceDescriptor`, Sepolia `0x7745e2dDC30e75E1D7B7fBAf4616Fc0F54e571F5` | @@ -27,7 +27,7 @@ items graduate only by moving up a label. | ERC editor review → Draft merge | Waiting (all CI green) | | Etherscan source verification of the Sepolia instance | **Done** (2026-07-31) — all four contracts source-verified, `docs/deployment.md` | | Independent second implementation passing the golden vectors | **Still open.** Three independent external recomputations exist (all by @babyblueviper1 / WYRIWE), and it is worth being exact about what each does and does not cover — none of them is a second implementation of the core registry:
1. **EIP-712 constants** (2026-07-26): the three typehashes and the domain separator re-derived from the type strings and domain fields, byte-for-byte.
2. **Interop layer** — [`preaction-governance-conformance`](https://github.com/babyblueviper1/preaction-governance-conformance): an executable checker for `attestation-refs.md` §2/§3/§6, run against this repository's live Sepolia fixture rather than synthetic data.
3. **Core layer, one delta** — [`invinoveritas/examples/erc8274-erc8350-composition`](https://github.com/babyblueviper1/invinoveritas/tree/main/examples/erc8274-erc8350-composition): `transitionId`, `provenanceCommitment`, `deltaCommitment` and `nextStateRoot` derived from the published formulas alone, re-verified here against `@erc-awar/core` from independently reconstructed bytes — every value matching.

What remains unclaimed is the part that matters most: **continuity**. It is worth being blunt about why. Until 2026-08-01 this row asked implementers to "drive `test-vectors/v1.json` end to end, including the rejection cases" — but v1 contains a single genesis delta and no rejection cases at all, so sequence monotonicity, prior-root linkage and every refusal were untestable from the published artifacts. The ask was unmeetable, and the gap was ours, not the ecosystem's. [`test-vectors/v2.json`](../../test-vectors/v2.json) fixes it: a 5-transition chain where each delta is only computable from the previous one's `nextStateRoot`, plus 12 rejection cases with their errors and the order they are checked in. `pnpm check:vectors` runs it against the TypeScript reference and `contracts/test/unit/VectorsV2.t.sol` drives the same file against the Solidity registry — so the bar is now a file an implementer can make pass, not a paragraph they have to interpret. | -| External Solidity audit | Before any non-test deployment | +| External Solidity audit | **G4 preparation complete; independent report pending** — exact source/scope, internal finding, residual-risk register, evidence gate, and open request are in `audits/g4/`; `pnpm security:rc` remains blocked | | `attestation_ref` interop with external verdict schemes (WYRIWE et al.) | **Specified** — see `docs/interop/attestation-refs.md` (joint note with WYRIWE): unordered set under `provenanceCommitment`, JCS-canonical, sorted by `decision_ref`, deduped by `(event_id, pubkey)`, `verify_url` advisory-only; never an eighth struct field. **Fixture Space live on Sepolia** (spaceId `0xfbe2…3564`, 5 witnessed transitions — seq 5 is an externally contributed attestation signed by a live operator key, exercising the three-valued authority outcome on real data; `docs/interop/fixture-space.md`) | ## Mid term — ZK profiles (Direction) diff --git a/docs/security/residual-risks.md b/docs/security/residual-risks.md new file mode 100644 index 0000000..3584b14 --- /dev/null +++ b/docs/security/residual-risks.md @@ -0,0 +1,28 @@ +# G4 Residual Risks + +The canonical, machine-checked register is +[`audits/g4/residual-risks.json`](../../audits/g4/residual-risks.json). This page +is the compact operational view. Accepted risks are not bugs hidden behind an +audit label; they are conditions a deployment must actively manage. + +| ID | Severity | Risk | Required deployment response | +|---|---:|---|---| +| `G4-R-001` | Medium | Controller compromise or one-step rotation error | Use reviewed multisig/policy control; simulate and independently verify rotation | +| `G4-R-002` | Medium | Compromised or permissive transition authorizer | Separate duties; rate-limit or threshold authorizations; monitor transitions | +| `G4-R-003` | Medium | EIP-7702 ECDSA fallback is still valid after delegate-policy rejection | Use a true contract-account authorizer when policy must be exclusive | +| `G4-R-004` | Low | Relayer censorship or delay | Keep direct or multiple submission paths and alert on stalled sequences | +| `G4-R-005` | Medium | Timing/profile/equality leakage and weak salts | Use fresh 32-byte salts, encryption, fresh nonces, and opaque profiles where needed | +| `G4-R-006` | Medium | Lost witness, salt, locator, or key makes commitments unopenable | Use redundant encrypted storage and tested restoration | +| `G4-R-007` | Low | Same-domain replay across temporary chain forks | Apply finality rules and reconcile after reorganizations | +| `G4-R-008` | Medium | ERC-1271 wallet bugs, upgrades, or gas griefing | Use reviewed wallets; test valid, invalid, revert, and malformed-return paths | +| `G4-R-009` | Medium | Proxy/wrapper changes behavior outside the immutable reference scope | Publish bytecode hashes; treat every wrapper or proxy as a new audit scope | +| `G4-R-010` | Low | Extension metadata reveals relationships and timing | Keep secrets out of extension calldata; omit extensions when metadata privacy matters | +| `G4-R-011` | Low | Audit/deletion records can be overstated | Present them only as scoped attestations with explicit off-chain policy | +| `G4-R-012` | High, mitigated | Legacy Sepolia AuditGrant has the pre-fix cross-Space bug | Never rely on that address; redeploy corrected, reviewed bytecode | + +G4 does not permit an unresolved Critical or High finding in the candidate. The +legacy Sepolia issue is marked mitigated because the address is deprecated and +excluded from production use; it is not an accepted risk in the corrected source. + +An independent reviewer must assess the complete accepted-risk set and map every +additional accepted finding back into the canonical register before G4 can pass. diff --git a/docs/threat-model.md b/docs/threat-model.md index e32fe49..29c1391 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -28,6 +28,7 @@ implicitly trusted by the core protocol. | Configuration replay | Per-Space `configNonce` | Compromised controller can still authorize a malicious update | | ECDSA malleability | Low-s validation and non-zero recovery | Wallet implementation defects | | Contract-wallet spoofing | EIP-1271 exact magic value and malformed-return checks | Bugs in the authorizer contract itself | +| Delegate policy treated as exclusive | ERC-1271 is attempted first, then the ERC-8350 canonical ECDSA fallback | A delegated EOA's underlying key remains an authorization path; use a contract-account authorizer when policy must be exclusive | | Dictionary attack on short memory | Secret salt or encrypted payload with fresh key and nonce | Public metadata and timing remain visible | | Locator disclosure | Only a salted locator commitment is public | Out-of-band witness recipients can disclose it | | Equality linkage | Fresh independent salts | Reused ciphertext, salts, or payload-side identifiers | @@ -49,6 +50,12 @@ implicitly trusted by the core protocol. 6. Testnet monitoring for event/indexer compatibility. 7. External Solidity audit before a Registry is presented as production-ready. +The complete G4 operational register, including owners, controls, deployment +requirements, and review triggers, is +[`docs/security/residual-risks.md`](security/residual-risks.md). G4 remains +incomplete until `pnpm security:rc` accepts independent review evidence for the +exact frozen source. + ## Non-claims A valid transition does not prove that memory is true, useful, available, diff --git a/erc/SUBMISSION.md b/erc/SUBMISSION.md index 28211a6..0f1895a 100644 --- a/erc/SUBMISSION.md +++ b/erc/SUBMISSION.md @@ -45,4 +45,6 @@ dependency-free Solidity reference: registry, interface, ERC-1271 interface, ECD - [x] Sepolia deployment — registry `0xDdf21937ba80b5fF973610877A0955b320C91241`, see `docs/deployment.md` (2026-07-26) - [x] Independent reproduction evidence — two external, commit-pinned executable suites pass; see `docs/interop/external-reproduction.md` (2026-07-31) - [ ] Independent second implementation passing `test-vectors/v1.json` -- [ ] External Solidity audit +- [ ] External Solidity audit — `G4-rc1` target, risk register, and evidence gate + prepared at source commit `af5a75f`; independent report remains pending, so + `pnpm security:rc` intentionally fails diff --git a/package.json b/package.json index 81d515c..2a4fad6 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,15 @@ "evidence:validate": "node scripts/external-reproduction-gate.mjs --validate-only", "evidence:test": "node --test scripts/external-reproduction-gate.test.mjs", "evidence:external": "node scripts/external-reproduction-gate.mjs", + "security:scope": "node scripts/security-rc-gate.mjs --scope-only", + "security:test": "node --test scripts/security-rc-gate.test.mjs", + "security:rc": "node scripts/security-rc-gate.mjs", "test": "pnpm test:ts && pnpm check:vectors && pnpm test:contracts", - "check": "pnpm clean && pnpm build && pnpm -r --if-present typecheck && pnpm -r --if-present test && pnpm check:erc-assets && pnpm check:vectors && pnpm evidence:validate && pnpm evidence:test && pnpm format:check && pnpm test:contracts", + "check": "pnpm clean && pnpm build && pnpm -r --if-present typecheck && pnpm -r --if-present test && pnpm check:erc-assets && pnpm check:vectors && pnpm evidence:validate && pnpm evidence:test && pnpm security:scope && pnpm security:test && pnpm format:check && pnpm test:contracts", "conformance:gate": "node scripts/conformance-gate.mjs", "conformance:clean": "node scripts/clean-checkout-conformance.mjs", "audit": "pnpm audit --audit-level=moderate", - "release:check": "pnpm conformance:gate && pnpm audit --audit-level=moderate" + "release:check": "pnpm conformance:gate && pnpm security:rc && pnpm audit --audit-level=moderate" }, "devDependencies": { "@noble/curves": "^2.2.0", diff --git a/scripts/security-rc-gate.mjs b/scripts/security-rc-gate.mjs new file mode 100644 index 0000000..088c8a4 --- /dev/null +++ b/scripts/security-rc-gate.mjs @@ -0,0 +1,563 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptDirectory, ".."); +const defaultScopePath = path.join(repositoryRoot, "audits", "g4", "scope.json"); +const defaultRiskPath = path.join( + repositoryRoot, + "audits", + "g4", + "residual-risks.json", +); +const defaultReviewPath = path.join( + repositoryRoot, + "audits", + "g4", + "external", + "external-review.json", +); + +const projectRepository = "https://github.com/AwareLiquid/ERC-8350"; +const projectOwners = new Set(["awareliquid", "xiaohai67890", "everest-an"]); +const requiredScopePaths = new Set([ + "contracts/src/ECDSA.sol", + "contracts/src/interfaces/IAgentMemoryState.sol", + "contracts/src/interfaces/IERC1271.sol", + "contracts/src/reference/AgentMemoryStateRegistry.sol", + "contracts/src/reference/PrivateCommitment.sol", + "contracts/src/extensions/AuditGrant.sol", + "contracts/src/extensions/DeletionAttestation.sol", + "contracts/src/extensions/SpaceDescriptor.sol", + "contracts/script/Deploy.s.sol", + "contracts/foundry.toml", +]); +const requiredEvidencePaths = new Set([ + "test-vectors/v1.json", + "contracts/test/unit/AuditGrant.t.sol", + "contracts/test/unit/AgentMemoryStateRegistry.t.sol", + "contracts/test/invariant/StateMachineInvariant.t.sol", +]); +const requiredCriteria = [ + "independentReviewerRequired", + "externalRepositoryRequired", + "exactSourceCommitRequired", + "sourceHashesRequired", + "publicReportRequired", + "reportDigestRequired", + "noUnresolvedCriticalOrHigh", + "allOtherFindingsDispositioned", + "residualRiskMappingRequired", +]; +const severities = ["critical", "high", "medium", "low", "informational"]; +const attestationStatement = + "I independently reviewed the exact G4 scope and the identified report is my work."; + +function parseArgs(argv) { + const result = { + scopeOnly: false, + scope: defaultScopePath, + risks: defaultRiskPath, + review: defaultReviewPath, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--scope-only") { + result.scopeOnly = true; + continue; + } + if (["--scope", "--risks", "--review"].includes(argument)) { + const value = argv[index + 1]; + if (!value) throw new Error(`${argument} requires a path`); + result[argument.slice(2)] = path.resolve(repositoryRoot, value); + index += 1; + continue; + } + throw new Error(`Unknown argument: ${argument}`); + } + + return result; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function isFullSha(value) { + return typeof value === "string" && /^[0-9a-f]{40}$/u.test(value); +} + +function isDigest(value) { + return typeof value === "string" && /^[0-9a-f]{64}$/u.test(value); +} + +function isSafeRelativePath(value) { + return ( + typeof value === "string" && + value.length > 0 && + !path.isAbsolute(value) && + !value.split(/[\\/]/u).includes("..") && + !value.includes("\0") + ); +} + +function resolveInside(base, relativePath, label) { + assert(isSafeRelativePath(relativePath), `${label} is not a safe relative path`); + const resolved = path.resolve(base, relativePath); + const relative = path.relative(base, resolved); + assert( + relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative), + `${label} escapes its allowed directory`, + ); + return resolved; +} + +function assertNonEmptyString(value, label) { + assert(typeof value === "string" && value.trim().length > 0, `${label} is required`); +} + +function parseHttpsUrl(value, label) { + assertNonEmptyString(value, label); + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${label} must be a valid HTTPS URL`); + } + assert( + parsed.protocol === "https:" && + !parsed.username && + !parsed.password && + !parsed.hash, + `${label} must be an HTTPS URL without credentials or a fragment`, + ); + return parsed; +} + +function parseGitHubRepository(value, label) { + const parsed = parseHttpsUrl(value, label); + const parts = parsed.pathname + .replace(/\/+$/u, "") + .replace(/\.git$/u, "") + .split("/") + .filter(Boolean); + assert( + parsed.hostname === "github.com" && + parsed.port === "" && + parsed.search === "" && + parts.length === 2 && + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/u.test(parts[0]) && + /^[A-Za-z0-9._-]+$/u.test(parts[1]), + `${label} must identify one public GitHub repository`, + ); + return { owner: parts[0], repository: parts[1] }; +} + +function assertIsoTimestamp(value, label) { + assertNonEmptyString(value, label); + const timestamp = Date.parse(value); + assert(Number.isFinite(timestamp), `${label} must be an ISO-8601 timestamp`); +} + +function git(args, options = {}) { + const result = spawnSync("git", args, { + cwd: repositoryRoot, + encoding: options.binary ? undefined : "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + if (result.status !== 0) { + const stderr = Buffer.isBuffer(result.stderr) + ? result.stderr.toString("utf8") + : result.stderr; + throw new Error(`git ${args.join(" ")} failed: ${stderr.trim()}`); + } + return options.binary ? result.stdout : result.stdout.trim(); +} + +async function readJson(filePath, label) { + let bytes; + try { + bytes = await readFile(filePath); + } catch (error) { + if (error.code === "ENOENT") { + throw new Error(`${label} is missing: ${filePath}`); + } + throw error; + } + + let value; + try { + value = JSON.parse(bytes.toString("utf8")); + } catch (error) { + throw new Error(`${label} is not valid JSON: ${error.message}`); + } + return { value, bytes, digest: sha256(bytes) }; +} + +function assertExactPathSet(entries, expected, label) { + assert(Array.isArray(entries), `${label} must be an array`); + const observed = new Set(); + for (const entry of entries) { + assertNonEmptyString(entry?.path, `${label} path`); + assert(!observed.has(entry.path), `${label} contains duplicate path ${entry.path}`); + observed.add(entry.path); + } + const missing = [...expected].filter((item) => !observed.has(item)); + const extra = [...observed].filter((item) => !expected.has(item)); + assert( + missing.length === 0 && extra.length === 0, + `${label} changed; missing=[${missing.join(", ")}] extra=[${extra.join(", ")}]`, + ); +} + +async function verifyRegularFile(filePath, allowedBase, label) { + const metadata = await lstat(filePath); + assert(metadata.isFile(), `${label} must be a regular file`); + const canonicalBase = await realpath(allowedBase); + const canonicalFile = await realpath(filePath); + const relative = path.relative(canonicalBase, canonicalFile); + assert( + relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative), + `${label} resolves outside its allowed directory`, + ); +} + +async function validatePinnedEntry(entry, sourceCommit, label) { + assert(isSafeRelativePath(entry.path), `${label} has an unsafe path`); + assert(isDigest(entry.sha256), `${label} ${entry.path} must pin a lowercase SHA-256`); + const currentPath = resolveInside(repositoryRoot, entry.path, `${label} ${entry.path}`); + await verifyRegularFile(currentPath, repositoryRoot, `${label} ${entry.path}`); + const currentBytes = await readFile(currentPath); + assert( + sha256(currentBytes) === entry.sha256, + `${label} ${entry.path} does not match its pinned SHA-256`, + ); + const committedBytes = git(["show", `${sourceCommit}:${entry.path}`], { binary: true }); + assert( + sha256(committedBytes) === entry.sha256, + `${label} ${entry.path} does not match source commit ${sourceCommit}`, + ); +} + +async function validateScope(scopePath) { + const record = await readJson(scopePath, "G4 scope manifest"); + const scope = record.value; + assert( + scope.schema === "erc-8350/security-review-scope/v1", + `Unsupported G4 scope schema: ${scope.schema}`, + ); + assert(scope.candidate === "G4-rc1", `Unexpected candidate: ${scope.candidate}`); + assert(scope.repository === projectRepository, "G4 scope repository changed"); + assert(isFullSha(scope.source?.commit), "G4 source commit must be a full SHA"); + assert(isFullSha(scope.source?.tree), "G4 source tree must be a full SHA"); + assert( + scope.source?.ref === "refs/tags/audit/g4-rc1-source", + "G4 source ref must be refs/tags/audit/g4-rc1-source", + ); + + const taggedCommit = git(["rev-parse", `${scope.source.ref}^{commit}`]); + assert(taggedCommit === scope.source.commit, "G4 source tag does not resolve to source commit"); + const commitTree = git(["rev-parse", `${scope.source.commit}^{tree}`]); + assert(commitTree === scope.source.tree, "G4 source tree does not match source commit"); + + for (const criterion of requiredCriteria) { + assert( + scope.acceptanceCriteria?.[criterion] === true, + `G4 acceptance criterion ${criterion} must remain true`, + ); + } + + assertExactPathSet(scope.inScope, requiredScopePaths, "G4 in-scope files"); + assertExactPathSet( + scope.supportingEvidence, + requiredEvidencePaths, + "G4 supporting evidence", + ); + for (const entry of [...scope.inScope, ...scope.supportingEvidence]) { + await validatePinnedEntry(entry, scope.source.commit, "G4 pinned file"); + } + + assert(Array.isArray(scope.excluded), "G4 exclusions must be an array"); + const market = scope.excluded.find( + (entry) => entry.path === "contracts/src/experimental/MemoryMarket.sol", + ); + assert(market, "G4 must explicitly exclude experimental MemoryMarket.sol"); + assertNonEmptyString(market.reason, "MemoryMarket exclusion reason"); + + assert(scope.toolchain?.solidity === "0.8.24", "G4 Solidity version changed"); + assert(scope.toolchain?.optimizer === true, "G4 optimizer must remain enabled"); + assert(scope.toolchain?.optimizerRuns === 200, "G4 optimizer runs changed"); + assert(scope.toolchain?.evmVersion === "cancun", "G4 EVM version changed"); + + return { scope, digest: record.digest }; +} + +function assertStringArray(value, label) { + assert( + Array.isArray(value) && + value.length > 0 && + value.every((item) => typeof item === "string" && item.trim().length > 0), + `${label} must be a non-empty string array`, + ); +} + +async function validateRisks(riskPath) { + const record = await readJson(riskPath, "G4 residual-risk register"); + const register = record.value; + assert( + register.schema === "erc-8350/residual-risk-register/v1", + `Unsupported residual-risk schema: ${register.schema}`, + ); + assert(register.candidate === "G4-rc1", "Residual-risk candidate changed"); + assert( + register.policy?.unresolvedCriticalOrHighAllowed === false, + "Residual-risk policy must reject unresolved Critical/High risks", + ); + assert( + register.policy?.reviewRequiredAfterSourceChange === true, + "Residual-risk policy must require review after source changes", + ); + assert(Array.isArray(register.risks) && register.risks.length > 0, "Risks are required"); + + const allIds = new Set(); + const acceptedIds = new Set(); + for (const risk of register.risks) { + assert(/^G4-R-\d{3}$/u.test(risk.id ?? ""), `Invalid residual-risk ID: ${risk.id}`); + assert(!allIds.has(risk.id), `Duplicate residual-risk ID: ${risk.id}`); + allIds.add(risk.id); + assertNonEmptyString(risk.title, `${risk.id} title`); + assert(severities.includes(risk.severity), `${risk.id} has invalid severity`); + assert( + ["low", "medium", "high"].includes(risk.likelihood), + `${risk.id} has invalid likelihood`, + ); + assert( + ["accepted", "mitigated"].includes(risk.status), + `${risk.id} has invalid status`, + ); + assertNonEmptyString(risk.description, `${risk.id} description`); + assertStringArray(risk.affectedComponents, `${risk.id} affectedComponents`); + assertStringArray(risk.existingControls, `${risk.id} existingControls`); + assertStringArray(risk.deploymentRequirements, `${risk.id} deploymentRequirements`); + assertNonEmptyString(risk.owner, `${risk.id} owner`); + assertNonEmptyString(risk.reviewTrigger, `${risk.id} reviewTrigger`); + if (risk.status === "accepted") { + assert( + !["critical", "high"].includes(risk.severity), + `${risk.id} leaves an unresolved ${risk.severity} risk`, + ); + acceptedIds.add(risk.id); + } + } + + return { register, digest: record.digest, allIds, acceptedIds }; +} + +function compareCounts(actual, expected) { + for (const severity of severities) { + assert( + expected?.[severity] === actual[severity], + `External-review unresolved ${severity} count must be ${actual[severity]}`, + ); + } +} + +async function validateExternalReview(reviewPath, scopeRecord, riskRecord) { + const record = await readJson(reviewPath, "G4 external-review evidence"); + const review = record.value; + assert( + review.schema === "erc-8350/external-solidity-review/v1", + `Unsupported external-review schema: ${review.schema}`, + ); + assert(review.candidate === scopeRecord.scope.candidate, "Review candidate mismatch"); + + const reviewer = review.reviewer ?? {}; + assertNonEmptyString(reviewer.name, "Reviewer name"); + assertNonEmptyString(reviewer.organization, "Reviewer organization"); + assert( + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/u.test(reviewer.githubOwner ?? ""), + "Reviewer GitHub owner is invalid", + ); + assert(reviewer.independentFromProject === true, "Reviewer must attest independence"); + assertNonEmptyString(reviewer.conflictDisclosure, "Reviewer conflict disclosure"); + assert( + !projectOwners.has(reviewer.githubOwner.toLowerCase()), + "Project owners cannot satisfy the independent external-review gate", + ); + const evidenceRepository = parseGitHubRepository( + reviewer.evidenceRepository, + "Reviewer evidenceRepository", + ); + assert( + evidenceRepository.owner.toLowerCase() === reviewer.githubOwner.toLowerCase(), + "Reviewer evidence repository must be controlled by reviewer.githubOwner", + ); + + const reviewedScope = review.scope ?? {}; + assert(reviewedScope.repository === projectRepository, "Reviewed repository mismatch"); + assert( + reviewedScope.commit === scopeRecord.scope.source.commit, + "Reviewed commit does not match the frozen G4 commit", + ); + assert( + reviewedScope.tree === scopeRecord.scope.source.tree, + "Reviewed tree does not match the frozen G4 tree", + ); + assert( + reviewedScope.manifestPath === "audits/g4/scope.json", + "Review must identify the canonical scope manifest", + ); + assert( + reviewedScope.manifestSha256 === scopeRecord.digest, + "Review scope-manifest SHA-256 mismatch", + ); + assert( + reviewedScope.riskRegisterPath === "audits/g4/residual-risks.json", + "Review must identify the canonical residual-risk register", + ); + assert( + reviewedScope.riskRegisterSha256 === riskRecord.digest, + "Review residual-risk SHA-256 mismatch", + ); + + const report = review.report ?? {}; + const reportUrl = parseHttpsUrl(report.url, "External report URL"); + assert(reportUrl.hostname.length > 0, "External report URL must have a host"); + assert(isDigest(report.sha256), "External report must pin a lowercase SHA-256"); + assertIsoTimestamp(report.publishedAt, "External report publishedAt"); + const reviewDirectory = path.dirname(reviewPath); + const reportPath = resolveInside(reviewDirectory, report.path, "External report path"); + await verifyRegularFile(reportPath, reviewDirectory, "External report"); + const reportBytes = await readFile(reportPath); + assert(sha256(reportBytes) === report.sha256, "External report SHA-256 mismatch"); + + assert(review.methodology?.manualReview === true, "External manual review is required"); + assertStringArray(review.methodology?.tools, "External-review tools"); + assertStringArray(review.methodology?.testCommands, "External-review testCommands"); + + assert(review.conclusion?.status === "pass", "External-review conclusion must pass"); + assert( + review.conclusion?.noUnresolvedCriticalOrHigh === true, + "External review must attest no unresolved Critical/High findings", + ); + assert(Array.isArray(review.findings), "External-review findings must be an array"); + + const findingIds = new Set(); + const unresolved = Object.fromEntries(severities.map((severity) => [severity, 0])); + for (const finding of review.findings) { + assert( + /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(finding.id ?? ""), + `Invalid external finding ID: ${finding.id}`, + ); + assert(!findingIds.has(finding.id), `Duplicate external finding ID: ${finding.id}`); + findingIds.add(finding.id); + assertNonEmptyString(finding.title, `${finding.id} title`); + assert(severities.includes(finding.severity), `${finding.id} has invalid severity`); + assert( + ["fixed", "accepted", "false-positive"].includes(finding.status), + `${finding.id} has invalid disposition`, + ); + assertNonEmptyString(finding.description, `${finding.id} description`); + if (finding.status === "fixed") { + assertNonEmptyString(finding.verification, `${finding.id} verification`); + } + if (finding.status === "false-positive") { + assertNonEmptyString(finding.rationale, `${finding.id} rationale`); + } + if (finding.status === "accepted") { + assert( + !["critical", "high"].includes(finding.severity), + `${finding.id} leaves an unresolved ${finding.severity} finding`, + ); + assert( + riskRecord.acceptedIds.has(finding.residualRiskId), + `${finding.id} must map to an accepted residual risk`, + ); + unresolved[finding.severity] += 1; + } + } + compareCounts(unresolved, review.conclusion.unresolved); + + assert( + Array.isArray(review.reviewedResidualRiskIds), + "reviewedResidualRiskIds must be an array", + ); + const reviewedRiskIds = new Set(review.reviewedResidualRiskIds); + assert( + reviewedRiskIds.size === review.reviewedResidualRiskIds.length, + "reviewedResidualRiskIds contains duplicates", + ); + const missingRisks = [...riskRecord.acceptedIds].filter( + (riskId) => !reviewedRiskIds.has(riskId), + ); + const unknownRisks = [...reviewedRiskIds].filter( + (riskId) => !riskRecord.acceptedIds.has(riskId), + ); + assert( + missingRisks.length === 0 && unknownRisks.length === 0, + `External reviewer did not assess the exact accepted-risk set; missing=[${missingRisks.join(", ")}] unknown=[${unknownRisks.join(", ")}]`, + ); + + assert( + review.attestation?.statement === attestationStatement, + "External-review attestation statement changed", + ); + assertIsoTimestamp(review.attestation?.signedAt, "External attestation signedAt"); + const attestationUrl = parseHttpsUrl( + review.attestation?.url, + "External attestation URL", + ); + const expectedPrefix = `/${evidenceRepository.owner}/${evidenceRepository.repository}/commit/`; + assert( + attestationUrl.hostname === "github.com" && + attestationUrl.pathname.startsWith(expectedPrefix) && + /[0-9a-f]{40}$/u.test(attestationUrl.pathname), + "External attestation URL must be a full commit in the reviewer's evidence repository", + ); + + return { review, digest: record.digest }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const scopeRecord = await validateScope(args.scope); + const riskRecord = await validateRisks(args.risks); + + console.log(`G4 scope: PASS (${scopeRecord.scope.inScope.length} pinned files)`); + console.log(`G4 source: ${scopeRecord.scope.source.commit}`); + console.log(`G4 scope SHA-256: ${scopeRecord.digest}`); + console.log( + `Residual risks: PASS (${riskRecord.acceptedIds.size} accepted, ${riskRecord.register.risks.length - riskRecord.acceptedIds.size} mitigated)`, + ); + console.log(`Risk-register SHA-256: ${riskRecord.digest}`); + + if (args.scopeOnly) { + console.log("G4 external review: PENDING (scope-only validation requested)"); + return; + } + + const reviewRecord = await validateExternalReview( + args.review, + scopeRecord, + riskRecord, + ); + console.log(`G4 external review: PASS (${reviewRecord.review.reviewer.name})`); + console.log(`External-review evidence SHA-256: ${reviewRecord.digest}`); + console.log("G4 security-ready RC: PASS"); +} + +main().catch((error) => { + console.error(`G4 security-ready RC: FAIL\n${error.message}`); + process.exitCode = 1; +}); diff --git a/scripts/security-rc-gate.test.mjs b/scripts/security-rc-gate.test.mjs new file mode 100644 index 0000000..a528b4d --- /dev/null +++ b/scripts/security-rc-gate.test.mjs @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptDirectory, ".."); +const gate = path.join(scriptDirectory, "security-rc-gate.mjs"); +const scopePath = path.join(repositoryRoot, "audits", "g4", "scope.json"); +const riskPath = path.join( + repositoryRoot, + "audits", + "g4", + "residual-risks.json", +); +const scopeBytes = await readFile(scopePath); +const riskBytes = await readFile(riskPath); +const canonicalScope = JSON.parse(scopeBytes.toString("utf8")); +const canonicalRisks = JSON.parse(riskBytes.toString("utf8")); +const acceptedRiskIds = canonicalRisks.risks + .filter((risk) => risk.status === "accepted") + .map((risk) => risk.id); + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function runGate(args) { + return spawnSync(process.execPath, [gate, ...args], { + cwd: repositoryRoot, + encoding: "utf8", + }); +} + +function outputOf(result) { + return `${result.stdout}\n${result.stderr}`; +} + +async function withSyntheticReview(mutate, verify) { + const temporaryRoot = await mkdtemp(path.join(tmpdir(), "erc8350-g4-review-test-")); + const reportPath = path.join(temporaryRoot, "report.md"); + const reviewPath = path.join(temporaryRoot, "external-review.json"); + const reportBytes = Buffer.from("# Independent synthetic gate fixture\n", "utf8"); + await writeFile(reportPath, reportBytes); + + const review = { + schema: "erc-8350/external-solidity-review/v1", + candidate: "G4-rc1", + reviewer: { + name: "Independent Test Reviewer", + organization: "Synthetic test fixture", + githubOwner: "independent-security", + evidenceRepository: + "https://github.com/independent-security/erc8350-review", + independentFromProject: true, + conflictDisclosure: "Synthetic test fixture; no project relationship.", + }, + scope: { + repository: "https://github.com/AwareLiquid/ERC-8350", + commit: canonicalScope.source.commit, + tree: canonicalScope.source.tree, + manifestPath: "audits/g4/scope.json", + manifestSha256: sha256(scopeBytes), + riskRegisterPath: "audits/g4/residual-risks.json", + riskRegisterSha256: sha256(riskBytes), + }, + report: { + path: "report.md", + url: "https://example.com/erc-8350-independent-review", + sha256: sha256(reportBytes), + publishedAt: "2026-07-31T00:00:00Z", + }, + methodology: { + manualReview: true, + tools: ["Synthetic fixture 1.0"], + testCommands: ["cd contracts && forge test -vv"], + }, + conclusion: { + status: "pass", + noUnresolvedCriticalOrHigh: true, + unresolved: { + critical: 0, + high: 0, + medium: 0, + low: 0, + informational: 0, + }, + }, + reviewedResidualRiskIds: acceptedRiskIds, + findings: [], + attestation: { + statement: + "I independently reviewed the exact G4 scope and the identified report is my work.", + url: `https://github.com/independent-security/erc8350-review/commit/${"1".repeat(40)}`, + signedAt: "2026-07-31T00:00:00Z", + }, + }; + + if (mutate) await mutate(review, { reportPath, reviewPath, temporaryRoot }); + await writeFile(reviewPath, `${JSON.stringify(review, null, 2)}\n`); + + try { + await verify(runGate(["--review", reviewPath])); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + +async function expectRejectedReview(mutate, expectedMessage) { + await withSyntheticReview(mutate, (result) => { + assert.notEqual(result.status, 0); + assert.match(outputOf(result), expectedMessage); + }); +} + +test("accepts the canonical G4 scope and residual-risk register", () => { + const result = runGate(["--scope-only"]); + assert.equal(result.status, 0, outputOf(result)); + assert.match(result.stdout, /G4 scope: PASS \(10 pinned files\)/u); + assert.match(result.stdout, /G4 external review: PENDING/u); +}); + +test("accepts structurally valid independent review evidence", async () => { + await withSyntheticReview(null, (result) => { + assert.equal(result.status, 0, outputOf(result)); + assert.match(result.stdout, /G4 security-ready RC: PASS/u); + }); +}); + +test("rejects self-review by a project owner", async () => { + await expectRejectedReview( + (review) => { + review.reviewer.githubOwner = "AwareLiquid"; + review.reviewer.evidenceRepository = + "https://github.com/AwareLiquid/security-review"; + review.attestation.url = + `https://github.com/AwareLiquid/security-review/commit/${"2".repeat(40)}`; + }, + /Project owners cannot satisfy/u, + ); +}); + +test("rejects a report whose bytes do not match its digest", async () => { + await expectRejectedReview( + (review) => { + review.report.sha256 = "0".repeat(64); + }, + /External report SHA-256 mismatch/u, + ); +}); + +test("rejects report path traversal", async () => { + await expectRejectedReview( + (review) => { + review.report.path = "../report.md"; + }, + /External report path is not a safe relative path/u, + ); +}); + +test("rejects an unresolved High finding", async () => { + await expectRejectedReview( + (review) => { + review.findings.push({ + id: "EXT-001", + title: "Unresolved High", + severity: "high", + status: "accepted", + description: "Synthetic unresolved finding.", + residualRiskId: "G4-R-001", + }); + review.conclusion.unresolved.high = 1; + }, + /leaves an unresolved high finding/u, + ); +}); + +test("rejects an accepted finding without residual-risk mapping", async () => { + await expectRejectedReview( + (review) => { + review.findings.push({ + id: "EXT-002", + title: "Unmapped Medium", + severity: "medium", + status: "accepted", + description: "Synthetic unmapped finding.", + residualRiskId: "G4-R-999", + }); + review.conclusion.unresolved.medium = 1; + }, + /must map to an accepted residual risk/u, + ); +}); + +test("rejects an incomplete residual-risk assessment", async () => { + await expectRejectedReview( + (review) => { + review.reviewedResidualRiskIds = review.reviewedResidualRiskIds.slice(1); + }, + /did not assess the exact accepted-risk set/u, + ); +}); + +test("rejects weakened scope acceptance criteria", async () => { + const temporaryRoot = await mkdtemp(path.join(tmpdir(), "erc8350-g4-scope-test-")); + const mutatedScopePath = path.join(temporaryRoot, "scope.json"); + const mutatedScope = structuredClone(canonicalScope); + mutatedScope.acceptanceCriteria.noUnresolvedCriticalOrHigh = false; + await writeFile(mutatedScopePath, `${JSON.stringify(mutatedScope, null, 2)}\n`); + + try { + const result = runGate(["--scope-only", "--scope", mutatedScopePath]); + assert.notEqual(result.status, 0); + assert.match(outputOf(result), /noUnresolvedCriticalOrHigh must remain true/u); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("rejects a changed scoped source hash", async () => { + const temporaryRoot = await mkdtemp(path.join(tmpdir(), "erc8350-g4-scope-test-")); + const mutatedScopePath = path.join(temporaryRoot, "scope.json"); + const mutatedScope = structuredClone(canonicalScope); + mutatedScope.inScope[0].sha256 = "0".repeat(64); + await writeFile(mutatedScopePath, `${JSON.stringify(mutatedScope, null, 2)}\n`); + + try { + const result = runGate(["--scope-only", "--scope", mutatedScopePath]); + assert.notEqual(result.status, 0); + assert.match(outputOf(result), /does not match its pinned SHA-256/u); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); From 5e85280ba65a0ea5da088818db46bfbe1b2a0058 Mon Sep 17 00:00:00 2001 From: Everest Date: Fri, 31 Jul 2026 07:17:10 +0800 Subject: [PATCH 3/5] docs(security): publish G4 external review request --- audits/g4/EXTERNAL_REVIEW_REQUEST.md | 3 +++ audits/g4/README.md | 5 +++++ docs/roadmap/README.md | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/audits/g4/EXTERNAL_REVIEW_REQUEST.md b/audits/g4/EXTERNAL_REVIEW_REQUEST.md index eba86ea..87715ac 100644 --- a/audits/g4/EXTERNAL_REVIEW_REQUEST.md +++ b/audits/g4/EXTERNAL_REVIEW_REQUEST.md @@ -7,6 +7,9 @@ contracts can be called a security-ready release candidate. This request is open to an established auditor, security researcher, or independent Solidity team with no maintainer role in AwareLiquid/ERC-8350. +Public coordination issue: +[AwareLiquid/ERC-8350#11](https://github.com/AwareLiquid/ERC-8350/issues/11). + ## Frozen target - Repository: https://github.com/AwareLiquid/ERC-8350 diff --git a/audits/g4/README.md b/audits/g4/README.md index 75ce9ca..2f60fa4 100644 --- a/audits/g4/README.md +++ b/audits/g4/README.md @@ -7,6 +7,11 @@ The independent external Solidity review is still open. Until a qualifying repor is accepted, `pnpm security:rc` and `pnpm release:check` fail by design, and no artifact from this branch may be described as security-ready or production-ready. +- Implementation and evidence PR: + [AwareLiquid/ERC-8350#10](https://github.com/AwareLiquid/ERC-8350/pull/10) +- Public independent-review request: + [AwareLiquid/ERC-8350#11](https://github.com/AwareLiquid/ERC-8350/issues/11) + ## Gate status | Requirement | Evidence | Status | diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index a087d55..d854c97 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -27,7 +27,7 @@ items graduate only by moving up a label. | ERC editor review → Draft merge | Waiting (all CI green) | | Etherscan source verification of the Sepolia instance | **Done** (2026-07-31) — all four contracts source-verified, `docs/deployment.md` | | Independent second implementation passing the golden vectors | **Still open.** Three independent external recomputations exist (all by @babyblueviper1 / WYRIWE), and it is worth being exact about what each does and does not cover — none of them is a second implementation of the core registry:
1. **EIP-712 constants** (2026-07-26): the three typehashes and the domain separator re-derived from the type strings and domain fields, byte-for-byte.
2. **Interop layer** — [`preaction-governance-conformance`](https://github.com/babyblueviper1/preaction-governance-conformance): an executable checker for `attestation-refs.md` §2/§3/§6, run against this repository's live Sepolia fixture rather than synthetic data.
3. **Core layer, one delta** — [`invinoveritas/examples/erc8274-erc8350-composition`](https://github.com/babyblueviper1/invinoveritas/tree/main/examples/erc8274-erc8350-composition): `transitionId`, `provenanceCommitment`, `deltaCommitment` and `nextStateRoot` derived from the published formulas alone, re-verified here against `@erc-awar/core` from independently reconstructed bytes — every value matching.

What remains unclaimed is the part that matters most: **continuity**. It is worth being blunt about why. Until 2026-08-01 this row asked implementers to "drive `test-vectors/v1.json` end to end, including the rejection cases" — but v1 contains a single genesis delta and no rejection cases at all, so sequence monotonicity, prior-root linkage and every refusal were untestable from the published artifacts. The ask was unmeetable, and the gap was ours, not the ecosystem's. [`test-vectors/v2.json`](../../test-vectors/v2.json) fixes it: a 5-transition chain where each delta is only computable from the previous one's `nextStateRoot`, plus 12 rejection cases with their errors and the order they are checked in. `pnpm check:vectors` runs it against the TypeScript reference and `contracts/test/unit/VectorsV2.t.sol` drives the same file against the Solidity registry — so the bar is now a file an implementer can make pass, not a paragraph they have to interpret. | -| External Solidity audit | **G4 preparation complete; independent report pending** — exact source/scope, internal finding, residual-risk register, evidence gate, and open request are in `audits/g4/`; `pnpm security:rc` remains blocked | +| External Solidity audit | **G4 preparation complete; independent report pending** — exact source/scope, internal finding, residual-risk register, and evidence gate are in `audits/g4/`; public review request [#11](https://github.com/AwareLiquid/ERC-8350/issues/11); `pnpm security:rc` remains blocked | | `attestation_ref` interop with external verdict schemes (WYRIWE et al.) | **Specified** — see `docs/interop/attestation-refs.md` (joint note with WYRIWE): unordered set under `provenanceCommitment`, JCS-canonical, sorted by `decision_ref`, deduped by `(event_id, pubkey)`, `verify_url` advisory-only; never an eighth struct field. **Fixture Space live on Sepolia** (spaceId `0xfbe2…3564`, 5 witnessed transitions — seq 5 is an externally contributed attestation signed by a live operator key, exercising the three-valued authority outcome on real data; `docs/interop/fixture-space.md`) | ## Mid term — ZK profiles (Direction) From 463155c8f457d204002fb4bf7232627bfa1a98de Mon Sep 17 00:00:00 2001 From: Everest Date: Fri, 31 Jul 2026 16:11:02 +0800 Subject: [PATCH 4/5] docs(security): disposition provisional G4 review --- CHANGELOG.md | 6 + SECURITY.md | 15 ++- audits/g4/EXTERNAL_REVIEW_REQUEST.md | 7 +- audits/g4/README.md | 11 +- audits/g4/external/REVIEWER_FOLLOWUP.md | 60 ++++++++++ .../g4/external/REVIEW_INTAKE_2026-07-31.md | 61 ++++++++++ .../test/unit/AuthorizationBoundary.t.sol | 113 ++++++++++++++++++ docs/roadmap/README.md | 2 +- docs/security/residual-risks.md | 5 + 9 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 audits/g4/external/REVIEWER_FOLLOWUP.md create mode 100644 audits/g4/external/REVIEW_INTAKE_2026-07-31.md create mode 100644 contracts/test/unit/AuthorizationBoundary.t.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4c13f..9a15b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable implementation and protocol changes are recorded here. ### Added +- Provisional external-review intake and reviewer follow-up for G4, plus focused + regression tests proving ERC-1271 `STATICCALL` blocks state-changing callbacks + and invalid ECDSA `v` values are rejected. - G4 security-ready RC preparation: a commit- and SHA-256-pinned Solidity audit scope, internal review record, canonical residual-risk register, external-review request/template, evidence-integrity workflow, and an adversarially tested gate. @@ -35,6 +38,9 @@ All notable implementation and protocol changes are recorded here. ### Changed +- The EIP-7702 residual ECDSA path is now called out prominently in the security + policy. The preliminary external-review email does not satisfy the G4 gate + until its scope/API discrepancies and reviewer-owned evidence are corrected. - Release checks now require the complete G4 external-review gate. The 2026-07-26 Sepolia `AuditGrant` address is explicitly deprecated and cannot be used as evidence for the corrected candidate. diff --git a/SECURITY.md b/SECURITY.md index 7d19ef4..871bc25 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,8 @@ The `G4-rc1` Solidity target is frozen at `af5a75fb2db532fd5603554083d8895a825c2de2`. Its scope, internal review, and remaining risks are recorded under [`audits/g4/`](audits/g4/). -Current status: **external review pending; G4 not complete**. +Current status: **provisional review received; corrections and publication +evidence pending; G4 not complete**. - `pnpm security:scope` verifies the source commit, tree, compiler settings, per-file hashes, full scope, and residual-risk register. @@ -21,6 +22,18 @@ Current status: **external review pending; G4 not complete**. - `contracts/src/experimental/MemoryMarket.sol` is outside G4 and must not inherit any security-ready label from the core review. +### EIP-7702 authorization warning + +Registry authorization is an ordered OR rule: a direct call by the signer, then +ERC-1271 approval, then canonical ECDSA recovery. For an EIP-7702-delegated EOA, +a delegate that rejects, reverts, pauses, or returns malformed ERC-1271 data does +not veto a valid signature from the EOA's underlying key. This residual ECDSA path +is an explicit protocol rule, not an ERC-1271 policy-enforcement guarantee. + +Use an independently deployed contract-account controller or authorizer when an +ERC-1271 policy must be the exclusive authorization path. Do not use a delegated +EOA for that requirement. This condition is tracked as `G4-R-003`. + The earlier Sepolia `AuditGrant` deployment predates the cross-Space authorization fix and is deprecated. See [`docs/security/residual-risks.md`](docs/security/residual-risks.md). diff --git a/audits/g4/EXTERNAL_REVIEW_REQUEST.md b/audits/g4/EXTERNAL_REVIEW_REQUEST.md index 87715ac..1bb7c2f 100644 --- a/audits/g4/EXTERNAL_REVIEW_REQUEST.md +++ b/audits/g4/EXTERNAL_REVIEW_REQUEST.md @@ -1,12 +1,17 @@ # External Solidity Review Request: ERC-8350 G4-rc1 -Status: **open; external review not yet complete** +Status: **provisional email received; corrections and evidence still required** ERC-8350 needs one independent Solidity security review before the reference contracts can be called a security-ready release candidate. This request is open to an established auditor, security researcher, or independent Solidity team with no maintainer role in AwareLiquid/ERC-8350. +The 2026-07-31 email review is recorded in +[`external/REVIEW_INTAKE_2026-07-31.md`](external/REVIEW_INTAKE_2026-07-31.md). +It does not complete G4 until the scope/API discrepancies are corrected and the +required reviewer-owned evidence package is published. + Public coordination issue: [AwareLiquid/ERC-8350#11](https://github.com/AwareLiquid/ERC-8350/issues/11). diff --git a/audits/g4/README.md b/audits/g4/README.md index 2f60fa4..776dc34 100644 --- a/audits/g4/README.md +++ b/audits/g4/README.md @@ -1,9 +1,12 @@ # G4 Security-Ready Release Candidate -Current status: **PREPARED, NOT COMPLETE** +Current status: **PROVISIONAL REVIEW RECEIVED, NOT COMPLETE** The source target, internal review, risk register, and evidence gate are ready. -The independent external Solidity review is still open. Until a qualifying report +An external-review email was received on 2026-07-31, but its scope/API references +and evidence package require correction before acceptance. See the +[`review intake`](external/REVIEW_INTAKE_2026-07-31.md) and +[`reviewer follow-up`](external/REVIEWER_FOLLOWUP.md). Until a qualifying report is accepted, `pnpm security:rc` and `pnpm release:check` fail by design, and no artifact from this branch may be described as security-ready or production-ready. @@ -20,9 +23,9 @@ artifact from this branch may be described as security-ready or production-ready | Internal review and regression fix | [`INTERNAL_REVIEW.md`](INTERNAL_REVIEW.md) | Pass | | Remaining risks explicitly registered | [`residual-risks.json`](residual-risks.json) | Pass | | Scope/evidence gate has adversarial tests | `scripts/security-rc-gate*.mjs` | Pass | -| Independent external Solidity report | [`EXTERNAL_REVIEW_REQUEST.md`](EXTERNAL_REVIEW_REQUEST.md) | **Pending** | +| Independent external Solidity report | Preliminary email received; [`intake`](external/REVIEW_INTAKE_2026-07-31.md) | **Pending corrections/evidence** | | No unresolved Critical/High external findings | Requires accepted external report | **Pending** | -| G4 full gate | `pnpm security:rc` | **Blocked by pending review** | +| G4 full gate | `pnpm security:rc` | **Blocked by unaccepted review** | ## Frozen source diff --git a/audits/g4/external/REVIEWER_FOLLOWUP.md b/audits/g4/external/REVIEWER_FOLLOWUP.md new file mode 100644 index 0000000..b805596 --- /dev/null +++ b/audits/g4/external/REVIEWER_FOLLOWUP.md @@ -0,0 +1,60 @@ +# Reviewer Follow-up: ERC-8350 G4-rc1 + +Subject: Required corrections and evidence for the ERC-8350 G4 review + +Thank you for reviewing the ERC-8350 G4 release candidate and for addressing the +priority authorization, replay, wallet, and extension questions. We have recorded +your email as a provisional review. Before we can accept it as the independent +G4 security artifact, please publish a corrected final report and evidence record. + +## Scope and API confirmation + +Please confirm that you reviewed the exact commit +`af5a75fb2db532fd5603554083d8895a825c2de2`, all ten files listed in +`audits/g4/scope.json`, scope SHA-256 +`c9db7d27957c86386d8842687a2d009c0ca03bc864307e88cfed0ee25f3ae9bf`, +and residual-risk SHA-256 +`307a52eb0bf512aea25565bf604d92fc6b1b2d64eb910595568a5f0c04aa6019`. + +The email refers to APIs that are absent from that commit. Please replace +`grantAudit`, `acknowledgeAudit`, `revokeAudit`, and `getSpaceInfo` with the +actual `grant`, `acknowledge`, `revoke`, `spaceAuthorization`, `head`, and +`transition` interfaces, or explain which source was reviewed. + +## Finding corrections + +- `LOW-1`: please scope the ECDSA fallback statement to EIP-7702-delegated EOAs. + An ordinary independently deployed contract account does not normally have a + known EOA private key for its address. ERC-8350 intentionally treats delegate + approval and underlying-key ECDSA as alternative authorization paths; delegate + rejection is not an authoritative denial. +- `LOW-2`: `_requireAuthorization` performs the ERC-1271 call before writes, but + it uses `STATICCALL`. Static context propagates through callback calls, so a + callback cannot execute Registry `SSTORE`. Direct-call and ECDSA paths call no + untrusted code. Please reclassify this as defense-in-depth/Informational or + provide a concrete exploitable trace. We added focused regression tests without + changing the frozen source. +- `INFO-1`: invalid `v` is rejected by `ECDSA.recover` as + `ECDSA.InvalidSignature`; it is not converted to Registry + `InvalidAuthorization`. Please correct the control-flow description. +- `INFO-2`: strict `sequence == current + 1` registration means every sequence + from 1 through the current head exists. Please remove the continuity concern or + explain a reachable gap. + +## Publication package + +Please provide: + +1. Reviewer name, organization/independent status, GitHub owner, independence, + compensation, prior-work, and conflict disclosure. +2. A public report in a reviewer-controlled repository, with stable URL, exact + bytes, SHA-256, and ISO-8601 publication time. +3. Manual-review method, tool versions, and commands executed. +4. Stable IDs, severity, status, rationale, and verification for every finding. +5. A full-commit attestation using the statement in + `external-review.template.json`. +6. A completed `external-review.json`, including all reviewed residual-risk IDs. + +Once these points are corrected, we can run the evidence gate, complete +maintainer review of independence and report quality, and determine whether G4 +can be marked security-ready. diff --git a/audits/g4/external/REVIEW_INTAKE_2026-07-31.md b/audits/g4/external/REVIEW_INTAKE_2026-07-31.md new file mode 100644 index 0000000..2498bd1 --- /dev/null +++ b/audits/g4/external/REVIEW_INTAKE_2026-07-31.md @@ -0,0 +1,61 @@ +# External Review Intake: 2026-07-31 + +Status: **PROVISIONAL; NOT ACCEPTED AS G4 EVIDENCE** + +The maintainers received an email titled "Independent Security Review — ERC-8350 +G4 Release Candidate." Its conclusion reports no Critical, High, or Medium +findings and identifies two Low observations and two Informational notes. The +technical feedback is useful, but the email is not yet a qualifying external +review artifact under the G4 evidence policy. + +## Confirmed target details + +- Claimed source commit: + `af5a75fb2db532fd5603554083d8895a825c2de2` +- Candidate tag: `audit/g4-rc1-source` +- Compiler: Solidity `0.8.24` +- Optimizer: enabled, 200 runs +- EVM target: Cancun +- Claimed result: no Critical, High, or Medium findings + +## Evidence corrections required + +1. Identify the reviewer by name, organization or independent status, and an + external GitHub owner. +2. Publish the report in a reviewer-controlled public repository and provide the + exact report bytes, stable URL, SHA-256, publication timestamp, and full-commit + attestation. +3. State methodology, tool versions, test commands, compensation, prior work, + and conflicts. +4. Attest to the complete ten-file scope in `scope.json`, its SHA-256, and the + canonical residual-risk-register SHA-256. +5. Replace non-existent API names in the report: + `grantAudit`, `acknowledgeAudit`, `revokeAudit`, and `getSpaceInfo`. + The reviewed candidate exposes `grant`, `acknowledge`, `revoke`, + `spaceAuthorization`, `head`, and `transition`. +6. Provide stable finding IDs, final statuses, verification notes, and residual + risk mappings in a completed `external-review.json`. + +The email names five contracts, while the canonical scope contains ten pinned +files: the ECDSA and commitment libraries, two interfaces, the Registry, three +extensions, deployment script, and compiler configuration. The reviewer must +confirm all ten were reviewed. + +## Technical disposition + +| Email item | Maintainer disposition | Required action | +|---|---|---| +| `LOW-1` ERC-1271 to ECDSA fallback | Accepted as a deployment-policy clarification already tracked by `G4-R-003` | The warning is now prominent in `SECURITY.md`; revise the report to distinguish EIP-7702 delegated EOAs from ordinary contract accounts | +| `LOW-2` missing `nonReentrant` | Not accepted as a Low finding on the stated rationale | Authorization invokes untrusted code with `STATICCALL`; static context propagates to callbacks and forbids Registry state writes. New tests exercise registration and authorization-update callbacks. Reclassify or provide an exploit trace | +| `INFO-1` no explicit `v` branch | Accepted as Informational | Invalid `v` yields a zero recovery and `ECDSA.InvalidSignature`; a regression test now pins this behavior | +| `INFO-2` range continuity | Not accepted as a missing continuity check | The Registry permits only `sequence == head + 1`, so every sequence from 1 through `head` exists by induction. `AuditGrant` also requires `1 <= from <= to <= head` | + +No scoped source or canonical risk-register bytes were changed in response to +this intake. The frozen commit, scope digest, and risk digest therefore remain +valid. + +## Gate consequence + +`pnpm security:scope` and `pnpm security:test` may pass. `pnpm security:rc` +must continue to fail until the corrected public report and completed, +reviewer-attested evidence record are present and accepted. diff --git a/contracts/test/unit/AuthorizationBoundary.t.sol b/contracts/test/unit/AuthorizationBoundary.t.sol new file mode 100644 index 0000000..f002280 --- /dev/null +++ b/contracts/test/unit/AuthorizationBoundary.t.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {ECDSA} from "../../src/ECDSA.sol"; +import {IAgentMemoryState} from "../../src/interfaces/IAgentMemoryState.sol"; +import {IERC1271} from "../../src/interfaces/IERC1271.sol"; +import {AgentMemoryStateRegistry} from "../../src/reference/AgentMemoryStateRegistry.sol"; + +/// @dev Attempts a state-changing callback while the Registry is validating this +/// wallet through STATICCALL. It approves only when the callback is rejected. +contract Reentrant1271Wallet { + AgentMemoryStateRegistry private immutable _registry; + bytes private _callback; + + constructor(AgentMemoryStateRegistry registry_) { + _registry = registry_; + } + + function setCallback(bytes calldata callback_) external { + _callback = callback_; + } + + function isValidSignature(bytes32, bytes calldata) external returns (bytes4) { + (bool success,) = address(_registry).call{gas: 100_000}(_callback); + return success ? bytes4(0xffffffff) : IERC1271.isValidSignature.selector; + } +} + +contract AuthorizationBoundaryTest is Test { + AgentMemoryStateRegistry internal registry; + + function setUp() public { + registry = new AgentMemoryStateRegistry(); + } + + function test_ERC1271StaticcallBlocksReentrantRegistration() public { + Reentrant1271Wallet wallet = new Reentrant1271Wallet(registry); + bytes32 outerSalt = keccak256("outer-space"); + bytes32 nestedSalt = keccak256("nested-space"); + bytes32 outerSpace = registry.deriveSpaceId(address(wallet), outerSalt); + bytes32 nestedSpace = registry.deriveSpaceId(address(wallet), nestedSalt); + + wallet.setCallback( + abi.encodeCall( + registry.registerSpace, + (nestedSpace, address(wallet), address(wallet), nestedSalt, bytes("")) + ) + ); + + registry.registerSpace(outerSpace, address(wallet), address(wallet), outerSalt, hex"01"); + + (address controller, address authorizer, uint64 nonce) = + registry.spaceAuthorization(outerSpace); + assertEq(controller, address(wallet)); + assertEq(authorizer, address(wallet)); + assertEq(nonce, 0); + + vm.expectRevert(AgentMemoryStateRegistry.UnknownSpace.selector); + registry.spaceAuthorization(nestedSpace); + } + + function test_ERC1271StaticcallBlocksReentrantAuthorizationUpdate() public { + Reentrant1271Wallet wallet = new Reentrant1271Wallet(registry); + bytes32 salt = keccak256("authorization-space"); + bytes32 spaceId = registry.deriveSpaceId(address(wallet), salt); + address expectedAuthorizer = address(0xA11CE); + address callbackTakeover = address(0xBAD); + + vm.prank(address(wallet)); + registry.registerSpace(spaceId, address(wallet), address(wallet), salt, ""); + + wallet.setCallback( + abi.encodeCall( + registry.updateSpaceAuthorization, + (spaceId, callbackTakeover, callbackTakeover, bytes("")) + ) + ); + + registry.updateSpaceAuthorization(spaceId, address(wallet), expectedAuthorizer, hex"01"); + + (address controller, address authorizer, uint64 nonce) = + registry.spaceAuthorization(spaceId); + assertEq(controller, address(wallet)); + assertEq(authorizer, expectedAuthorizer); + assertEq(nonce, 1); + } + + function test_RevertWhen_ECDSASignatureVIsInvalid() public { + uint256 controllerPk = 0xA11CE; + address controller = vm.addr(controllerPk); + bytes32 salt = keccak256("invalid-v-space"); + bytes32 spaceId = registry.deriveSpaceId(controller, salt); + + vm.prank(controller); + registry.registerSpace(spaceId, controller, controller, salt, ""); + + IAgentMemoryState.ExperienceDelta memory delta = IAgentMemoryState.ExperienceDelta({ + spaceId: spaceId, + sequence: 1, + prevStateRoot: bytes32(0), + deltaCommitment: bytes32(uint256(0xAA)), + provenanceCommitment: bytes32(0), + profileId: keccak256("example/profile/v1"), + locatorCommitment: bytes32(uint256(0xBB)) + }); + bytes32 digest = registry.signingDigest(registry.hashExperienceDelta(delta)); + (, bytes32 r, bytes32 s) = vm.sign(controllerPk, digest); + + vm.expectRevert(ECDSA.InvalidSignature.selector); + registry.commitTransition(delta, abi.encodePacked(r, s, uint8(29))); + } +} diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index d854c97..7be9307 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -27,7 +27,7 @@ items graduate only by moving up a label. | ERC editor review → Draft merge | Waiting (all CI green) | | Etherscan source verification of the Sepolia instance | **Done** (2026-07-31) — all four contracts source-verified, `docs/deployment.md` | | Independent second implementation passing the golden vectors | **Still open.** Three independent external recomputations exist (all by @babyblueviper1 / WYRIWE), and it is worth being exact about what each does and does not cover — none of them is a second implementation of the core registry:
1. **EIP-712 constants** (2026-07-26): the three typehashes and the domain separator re-derived from the type strings and domain fields, byte-for-byte.
2. **Interop layer** — [`preaction-governance-conformance`](https://github.com/babyblueviper1/preaction-governance-conformance): an executable checker for `attestation-refs.md` §2/§3/§6, run against this repository's live Sepolia fixture rather than synthetic data.
3. **Core layer, one delta** — [`invinoveritas/examples/erc8274-erc8350-composition`](https://github.com/babyblueviper1/invinoveritas/tree/main/examples/erc8274-erc8350-composition): `transitionId`, `provenanceCommitment`, `deltaCommitment` and `nextStateRoot` derived from the published formulas alone, re-verified here against `@erc-awar/core` from independently reconstructed bytes — every value matching.

What remains unclaimed is the part that matters most: **continuity**. It is worth being blunt about why. Until 2026-08-01 this row asked implementers to "drive `test-vectors/v1.json` end to end, including the rejection cases" — but v1 contains a single genesis delta and no rejection cases at all, so sequence monotonicity, prior-root linkage and every refusal were untestable from the published artifacts. The ask was unmeetable, and the gap was ours, not the ecosystem's. [`test-vectors/v2.json`](../../test-vectors/v2.json) fixes it: a 5-transition chain where each delta is only computable from the previous one's `nextStateRoot`, plus 12 rejection cases with their errors and the order they are checked in. `pnpm check:vectors` runs it against the TypeScript reference and `contracts/test/unit/VectorsV2.t.sol` drives the same file against the Solidity registry — so the bar is now a file an implementer can make pass, not a paragraph they have to interpret. | -| External Solidity audit | **G4 preparation complete; independent report pending** — exact source/scope, internal finding, residual-risk register, and evidence gate are in `audits/g4/`; public review request [#11](https://github.com/AwareLiquid/ERC-8350/issues/11); `pnpm security:rc` remains blocked | +| External Solidity audit | **Provisional review received; corrections/evidence pending** — the 2026-07-31 email reports no Critical/High/Medium findings, but has scope/API discrepancies and lacks reviewer-owned publication evidence; disposition is recorded in `audits/g4/external/REVIEW_INTAKE_2026-07-31.md`; `pnpm security:rc` remains blocked | | `attestation_ref` interop with external verdict schemes (WYRIWE et al.) | **Specified** — see `docs/interop/attestation-refs.md` (joint note with WYRIWE): unordered set under `provenanceCommitment`, JCS-canonical, sorted by `decision_ref`, deduped by `(event_id, pubkey)`, `verify_url` advisory-only; never an eighth struct field. **Fixture Space live on Sepolia** (spaceId `0xfbe2…3564`, 5 witnessed transitions — seq 5 is an externally contributed attestation signed by a live operator key, exercising the three-valued authority outcome on real data; `docs/interop/fixture-space.md`) | ## Mid term — ZK profiles (Direction) diff --git a/docs/security/residual-risks.md b/docs/security/residual-risks.md index 3584b14..0fdf428 100644 --- a/docs/security/residual-risks.md +++ b/docs/security/residual-risks.md @@ -24,5 +24,10 @@ G4 does not permit an unresolved Critical or High finding in the candidate. The legacy Sepolia issue is marked mitigated because the address is deprecated and excluded from production use; it is not an accepted risk in the corrected source. +`G4-R-003` is specifically an EIP-7702 delegated-EOA rule. ERC-1271 rejection, +revert, pause, or malformed return from the delegate does not veto a canonical +signature from the delegated EOA's underlying key. An ordinary independently +deployed contract account does not normally have that residual EOA-key path. + An independent reviewer must assess the complete accepted-risk set and map every additional accepted finding back into the canonical register before G4 can pass. From 1bf9768c0226f95873021905e0013c4568a4b284 Mon Sep 17 00:00:00 2001 From: Everest An Date: Mon, 3 Aug 2026 08:52:57 +1000 Subject: [PATCH 5/5] fix(security): anchor the G4 scope gate to the frozen commit, not the checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validatePinnedEntry asserted each pinned SHA-256 twice: once against the working tree and once against the frozen review commit. Only the second is meaningful. The review targets refs/tags/audit/g4-rc1-source (af5a75fb), which is what an external reviewer fetches and cannot drift once published; main legitimately advances underneath it. Rebasing this branch onto a main that had added comments to contracts/foundry.toml was enough to make the gate unrunnable, with no defect anywhere in the review candidate. The frozen-commit comparison is now the sole authoritative check. Divergence between the branch and the freeze is still surfaced — it is real information — but as a report rather than a failure. It compares committed bytes at HEAD, not the working tree, so the answer does not change with a checkout's line-ending normalisation (on a CRLF checkout every text file "differed", which made the old check fail for a reason that had nothing to do with the audit). This is the same distinction §6.1 of docs/interop/attestation-refs.md draws: "the check ran and failed" is not "the check could not have run". Collapsing the two is what turned a routine rebase into a red security gate. Tests: the tampered-pin case now asserts the frozen-commit rejection (the security property is unchanged — a mutated hash is still rejected); added a case pinning that post-freeze drift reports and exits 0. 11/11 pass. --- scripts/security-rc-gate.mjs | 44 ++++++++++++++++++++++++------- scripts/security-rc-gate.test.mjs | 15 ++++++++++- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/scripts/security-rc-gate.mjs b/scripts/security-rc-gate.mjs index 088c8a4..d8b802e 100644 --- a/scripts/security-rc-gate.mjs +++ b/scripts/security-rc-gate.mjs @@ -236,18 +236,30 @@ async function verifyRegularFile(filePath, allowedBase, label) { async function validatePinnedEntry(entry, sourceCommit, label) { assert(isSafeRelativePath(entry.path), `${label} has an unsafe path`); assert(isDigest(entry.sha256), `${label} ${entry.path} must pin a lowercase SHA-256`); - const currentPath = resolveInside(repositoryRoot, entry.path, `${label} ${entry.path}`); - await verifyRegularFile(currentPath, repositoryRoot, `${label} ${entry.path}`); - const currentBytes = await readFile(currentPath); - assert( - sha256(currentBytes) === entry.sha256, - `${label} ${entry.path} does not match its pinned SHA-256`, - ); + + // Authoritative check: the pin must match the bytes at the frozen review commit. + // That commit is anchored by refs/tags/audit/g4-rc1-source (verified above) and is + // what an external reviewer fetches, so it cannot drift once published. const committedBytes = git(["show", `${sourceCommit}:${entry.path}`], { binary: true }); assert( sha256(committedBytes) === entry.sha256, `${label} ${entry.path} does not match source commit ${sourceCommit}`, ); + + // The path must still exist in this checkout — a scope entry naming a file that has + // been deleted or replaced by a directory is a manifest defect, not drift. + const currentPath = resolveInside(repositoryRoot, entry.path, `${label} ${entry.path}`); + await verifyRegularFile(currentPath, repositoryRoot, `${label} ${entry.path}`); + + // Informational: has the branch moved on since the freeze? Compared against the + // committed bytes at HEAD rather than the working tree, so the answer does not change + // with the checkout's line-ending normalisation. main legitimately advances while a + // review candidate stays pinned — comments, formatting, docs — so divergence is + // reported, never fatal. Failing here would conflate "the audited artifact is wrong" + // with "the branch this sits on has moved", and the second is not a defect in the + // review candidate. + const headBytes = git(["show", `HEAD:${entry.path}`], { binary: true }); + return sha256(headBytes) === entry.sha256 ? null : entry.path; } async function validateScope(scopePath) { @@ -284,8 +296,10 @@ async function validateScope(scopePath) { requiredEvidencePaths, "G4 supporting evidence", ); + const driftedFromFreeze = []; for (const entry of [...scope.inScope, ...scope.supportingEvidence]) { - await validatePinnedEntry(entry, scope.source.commit, "G4 pinned file"); + const drifted = await validatePinnedEntry(entry, scope.source.commit, "G4 pinned file"); + if (drifted) driftedFromFreeze.push(drifted); } assert(Array.isArray(scope.excluded), "G4 exclusions must be an array"); @@ -300,7 +314,7 @@ async function validateScope(scopePath) { assert(scope.toolchain?.optimizerRuns === 200, "G4 optimizer runs changed"); assert(scope.toolchain?.evmVersion === "cancun", "G4 EVM version changed"); - return { scope, digest: record.digest }; + return { scope, digest: record.digest, driftedFromFreeze }; } function assertStringArray(value, label) { @@ -535,8 +549,18 @@ async function main() { const riskRecord = await validateRisks(args.risks); console.log(`G4 scope: PASS (${scopeRecord.scope.inScope.length} pinned files)`); - console.log(`G4 source: ${scopeRecord.scope.source.commit}`); + console.log(`G4 source: ${scopeRecord.scope.source.commit} (${scopeRecord.scope.source.ref})`); console.log(`G4 scope SHA-256: ${scopeRecord.digest}`); + if (scopeRecord.driftedFromFreeze.length > 0) { + console.log( + `G4 post-freeze drift: ${scopeRecord.driftedFromFreeze.length} pinned path(s) differ from the frozen commit (informational — the review targets the frozen commit, not this branch):`, + ); + for (const droppedPath of scopeRecord.driftedFromFreeze) { + console.log(` - ${droppedPath}`); + } + } else { + console.log("G4 post-freeze drift: none"); + } console.log( `Residual risks: PASS (${riskRecord.acceptedIds.size} accepted, ${riskRecord.register.risks.length - riskRecord.acceptedIds.size} mitigated)`, ); diff --git a/scripts/security-rc-gate.test.mjs b/scripts/security-rc-gate.test.mjs index a528b4d..2ca851f 100644 --- a/scripts/security-rc-gate.test.mjs +++ b/scripts/security-rc-gate.test.mjs @@ -231,8 +231,21 @@ test("rejects a changed scoped source hash", async () => { try { const result = runGate(["--scope-only", "--scope", mutatedScopePath]); assert.notEqual(result.status, 0); - assert.match(outputOf(result), /does not match its pinned SHA-256/u); + // The pin is authoritative against the frozen review commit, so that is the + // comparison a tampered hash has to fail. + assert.match(outputOf(result), /does not match source commit/u); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } }); + +test("reports post-freeze drift without failing the gate", async () => { + // A pinned path that has legitimately moved on since the freeze must be surfaced, + // but must not be treated as a defect in the review candidate: the review targets + // the frozen commit, and main advancing underneath it is expected. This is the + // "the check could not have failed" / "the check failed" distinction — collapsing + // the two is what made this gate unrunnable after a routine rebase. + const result = runGate(["--scope-only"]); + assert.equal(result.status, 0); + assert.match(outputOf(result), /G4 post-freeze drift:/u); +});