Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
a4d47ef
issue #82: ERC-7730 clear-signing + EIP-712 typed-data sign (v2-aligned)
WildmetaAgent May 20, 2026
9870111
issue #97: arch.md §15.3a — AuditEnvelope v1 canonical schema
WildmetaAgent May 21, 2026
7738166
issue #97 phase B: AuditEnvelope v1 struct + worker V2 endpoints
WildmetaAgent May 21, 2026
13e4ef4
issue #97 phase B: AuditClient — convenience HTTP client for the V2 e…
WildmetaAgent May 21, 2026
7397e28
issue #97 phase C: CredentialAudit.appendV2 + appendRootV2 (contract …
WildmetaAgent May 21, 2026
0df87c0
issue #97: recursive op_body canonicalization + arch.md event sig fix
WildmetaAgent May 21, 2026
e5fa4ba
Merge origin/main: resolve cmd_* import conflict (post-#96 cleanup)
WildmetaAgent May 21, 2026
a739516
docs+ops: add-op-kind ritual + setup-heima orchestrator + idempotency…
WildmetaAgent May 21, 2026
7e49429
wiki(add-op-kind): detail the explorer-side update (indexer + UI)
WildmetaAgent May 21, 2026
69540f2
K11 WebAuthn: render operator-readable intent on the confirmation page
WildmetaAgent May 21, 2026
7f81e41
heima-device-add: idempotency check — skip if companion already on-chain
WildmetaAgent May 21, 2026
bd6cd29
codex review fixes (PR #95): 3 P1 + 3 P2 findings addressed
WildmetaAgent May 21, 2026
5b46a79
K11 WebAuthn: wire intent text through CLI + harness call sites
WildmetaAgent May 21, 2026
238d8ff
aws: surface STS error source chain so 'dispatch failure' reveals WHY
WildmetaAgent May 21, 2026
d58aab1
heima k11 wrappers: stop swallowing \`agentkeys k11 assert\` stderr
WildmetaAgent May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,34 @@ Also: never gloss over a partial implementation in a demo doc or runbook. If the
## Remote broker host (single entry point)
All remote-host changes (binary upgrades, systemd edits, nginx/certbot, env tweaks, mock-server redeploys) MUST go through `bash scripts/setup-broker-host.sh` — it's idempotent and auto-detects bootstrap vs upgrade. No ad-hoc `systemctl` edits or hand-built `scp`.

## Heima chain (single entry point)
All chain bring-up + per-actor binding ceremonies (contract deploy, deployer funding, master device registration, agent creation, scope grants, K11 enrollment, audit-row append, worker smoke) MUST go through `bash scripts/setup-heima.sh` — it's idempotent and orchestrates the existing per-action `heima-*.sh` helpers in order. Same posture as `setup-broker-host.sh`: one command, every step pre-checks state + short-circuits when already done. The per-action helpers stay callable directly for surgical re-runs (`bash scripts/heima-scope-set.sh ...`); `setup-heima.sh` is the end-to-end orchestrator.

## Idempotent remote-setup rule (CLOUD / BLOCKCHAIN / CI / VM)
**Every script that mutates remote state — AWS / Heima / CI runners / EC2 VMs / Cloudflare / Tencent / IAM / DNS — MUST be idempotent.** A second run with the same inputs MUST exit 0 without re-applying the mutation. This is non-negotiable because:

1. **Operators re-run scripts.** Cloud setup is slow + flaky; a retry-from-the-start posture catches transient failures gracefully only when re-runs are safe.
2. **CI / CD pipelines re-run scripts.** Every CI redeploy or VM provision invokes the same script; non-idempotent scripts double-create resources, double-fund accounts, double-bill operators.
3. **The harness re-runs scripts.** `harness/v2-stage{1,2,3}-demo.sh` invokes every chain helper on every run. A non-idempotent helper means the harness can't be used as a regression gate.

Concrete shape for idempotent scripts (per the existing `setup-broker-host.sh` / `heima-*.sh` patterns):

| Mutation type | Pre-check before mutating | Short-circuit shape |
|---|---|---|
| Contract deploy | `cast code <addr>` — non-empty means deployed | `skip already-deployed` (log + exit 0) |
| Chain tx (register / scope / audit append) | `cast call <view-fn>` returning canonical state | `skip already-registered` / `skip config-matches` |
| Fund EVM account | `cast balance` ≥ requested amount | `skip already-funded` |
| AWS resource (bucket / role / policy) | `aws s3api head-bucket` / `aws iam get-role` | `skip already-exists` + best-effort `update-*` for drift |
| Systemd unit | Diff existing `/etc/systemd/system/<unit>` vs target | Write only if drift; `systemctl daemon-reload` only when written |
| Env-var file | Diff existing file vs target content | Write only if drift |
| nginx vhost | Diff existing `/etc/nginx/sites-available/<site>` vs target | Write + reload only if drift |
| DNS A record (Route 53) | `aws route53 list-resource-record-sets` for the name | UPSERT change-batch (no-op when value matches) |
| Key generation (keypair file) | `[ -f <path> ]` | `skip already-exists` (NEVER overwrite — would invalidate downstream encrypted blobs) |

Output convention: every script logs one of three outcomes per step — `ok proceeding` (mutation applied), `skip <reason>` (no-op), or `fail <reason>` (hard error, exit non-zero). The harness reads these to compute green/red per step.

If a remote-setup script you're writing CAN'T be made idempotent (e.g., one-shot CAS-burn cap-token mint, append-only audit event), explicitly call it out in the script header AND in the runbook ("step N is intentionally append-only; re-runs add a fresh row + advance entryCount"). Otherwise: idempotent or it doesn't ship.

## AWS local-profile ↔ remote-IAM mapping
Operator workstations use lowercase AWS profile names; the access key/secret inside each profile authenticates as the corresponding remote IAM user (case differences like `agentKeys-admin` on AWS vs `agentkeys-admin` locally are cosmetic — the key is the binding, not the name). Source-of-truth (`awsp` output):

Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion crates/agentkeys-broker-server/src/sts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,16 @@ impl StsClient for AwsStsClient {
.send()
.await
.map_err(|e| {
BrokerError::StsError(format!("assume_role_with_web_identity: {}", e))
// Flatten the SDK error's source chain — `DispatchFailure`
// and friends render uselessly via `{}` alone, the real
// cause (DNS / TCP / TLS / no-connector) is in source().
let mut msg = format!("assume_role_with_web_identity: {e}");
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
while let Some(next) = src {
msg.push_str(&format!(" | caused by: {next}"));
src = next.source();
}
BrokerError::StsError(msg)
})?;

let creds = resp
Expand Down
69 changes: 69 additions & 0 deletions crates/agentkeys-chain/src/CredentialAudit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,75 @@ contract CredentialAudit {
return roots[operatorOmni].length;
}

// ─── V2 surface — `AuditEnvelope v1` (arch.md §15.3a, issue #97 phase C) ──
//
// V2 is event-only. The full envelope lives off-chain at the audit-service
// worker, addressed by `envelopeHash`. The chain commits only
// `(opKind, envelopeHash)` so the contract stays op-kind-agnostic — new
// op_kinds need ZERO contract redeploys (non-break invariant #6).
//
// V1 surface (`append` + `appendRoot` above) is retained so existing
// indexers + the live tier-A worker keep working through the migration.

/// @notice Emitted by `appendV2`. The `opKind` topic is indexed so
/// explorers can filter "all this operator's typed-data signs"
/// via a single `eth_getLogs` call without scanning every row.
event AuditAppendedV2(
bytes32 indexed operatorOmni,
bytes32 indexed actorOmni,
uint8 indexed opKind,
bytes32 envelopeHash
);

/// @notice Emitted by `appendRootV2`. `opKindBitmap` is `bytes32` where
/// each set bit corresponds to an op_kind byte present in the
/// batch (bit N = op_kind N). Explorers filter root batches by
/// op_kind without fetching every leaf.
event AuditRootAppendedV2(
bytes32 indexed operatorOmni,
bytes32 indexed merkleRoot,
bytes32 opKindBitmap,
uint64 entryCount
);

/// @notice Append a single audit envelope commitment. `envelopeHash` is
/// `keccak256(canonical_cbor(AuditEnvelope))`; the worker
/// (`agentkeys-worker-audit`) holds the full envelope at
/// `GET /v1/audit/envelope/<envelopeHash>`.
///
/// @dev Open to any caller, same as V1 `append` — chain ordering +
/// indexed topic filtering is the primary safety. Spam-resistance
/// is via gas cost.
function appendV2(
bytes32 operatorOmni,
bytes32 actorOmni,
uint8 opKind,
bytes32 envelopeHash
) external {
emit AuditAppendedV2(operatorOmni, actorOmni, opKind, envelopeHash);
}

/// @notice Commit one Merkle root summarising a tier-A batch of
/// envelopes. Gated to the operator's master wallet (same as
/// V1 `appendRoot`).
///
/// @param opKindBitmap Each bit indexes one of 256 possible op_kinds
/// present in the batch. Bit N = op_kind N.
/// Lets explorers filter batches by op_kind
/// without fetching every leaf from the worker.
function appendRootV2(
bytes32 operatorOmni,
bytes32 merkleRoot,
bytes32 opKindBitmap,
uint64 batchEntryCount
) external {
address master = registry.operatorMasterWallet(operatorOmni);
if (master == address(0) || msg.sender != master) {
revert NotOperatorMaster(msg.sender, master);
}
emit AuditRootAppendedV2(operatorOmni, merkleRoot, opKindBitmap, batchEntryCount);
}

function getRoot(bytes32 operatorOmni, uint256 rootIndex)
external
view
Expand Down
87 changes: 87 additions & 0 deletions crates/agentkeys-chain/test/AgentKeysV1.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ import {CredentialAudit} from "../src/CredentialAudit.sol";
/// produce the full (authData || clientDataJSON || r, s) chain bound
/// to a contract-computed challenge.
contract AgentKeysV1Test is Test {
// Local copies of CredentialAudit V2 events so `vm.expectEmit` can
// match by topic+data. The event signatures MUST match
// `CredentialAudit.sol` exactly — drift caught by `expectEmit`.
event AuditAppendedV2(
bytes32 indexed operatorOmni,
bytes32 indexed actorOmni,
uint8 indexed opKind,
bytes32 envelopeHash
);
event AuditRootAppendedV2(
bytes32 indexed operatorOmni,
bytes32 indexed merkleRoot,
bytes32 opKindBitmap,
uint64 entryCount
);

P256Verifier p256;
K11Verifier k11;
SidecarRegistry registry;
Expand Down Expand Up @@ -339,6 +355,77 @@ contract AgentKeysV1Test is Test {
audit.appendRoot(operatorOmni, root, 1);
}

// ─── V2 envelope path (arch.md §15.3a, issue #97 phase C) ─────────────

function test_CredentialAudit_AppendV2_EmitsEvent() public {
bytes32 envelopeHash = keccak256("test-envelope");
uint8 opKind = 21; // SignEip712

// The event topics MUST carry operator, actor, and opKind so
// explorers can filter `eth_getLogs` by any of the three.
vm.expectEmit(true, true, true, true);
emit AuditAppendedV2(operatorOmni, actorOmniAgentA, opKind, envelopeHash);
audit.appendV2(operatorOmni, actorOmniAgentA, opKind, envelopeHash);
}

function test_CredentialAudit_AppendV2_AcceptsAnyOpKind() public {
// Per non-break invariant #1, the contract is op-kind-agnostic —
// any byte 0..255 must be accepted. Adding a new op_kind needs
// ZERO contract redeploys.
bytes32 envelopeHash = keccak256("future");
vm.expectEmit(true, true, true, true);
emit AuditAppendedV2(operatorOmni, actorOmniAgentA, 250, envelopeHash);
audit.appendV2(operatorOmni, actorOmniAgentA, 250, envelopeHash);
}

function test_CredentialAudit_AppendV2_OpenToAnyCaller() public {
// V2 `appendV2` is gated only by chain ordering + gas (same as
// V1 `append`). Attacker can append, but the operator can prove
// forgery via the indexer's view of canonical envelope hashes.
bytes32 envelopeHash = keccak256("attacker-claim");
vm.prank(attacker);
audit.appendV2(operatorOmni, actorOmniAgentA, 0, envelopeHash);
// No revert — the attacker emit is just noise the indexer filters.
}

function test_CredentialAudit_AppendRootV2_EmitsEvent() public {
_registerFirstMaster();
bytes32 root = keccak256("v2-root");
// bit 0 (CredStore) + bit 21 (SignEip712) + bit 40 (ScopeGrant)
bytes32 bitmap = bytes32(uint256((1 << 0) | (1 << 21) | (uint256(1) << 40)));

vm.expectEmit(true, true, true, true);
emit AuditRootAppendedV2(operatorOmni, root, bitmap, 3);
vm.prank(master);
audit.appendRootV2(operatorOmni, root, bitmap, 3);
}

function test_CredentialAudit_AppendRootV2_RejectsNonMaster() public {
_registerFirstMaster();
bytes32 root = keccak256("dummy");
bytes32 bitmap = bytes32(uint256(1));
vm.prank(attacker);
vm.expectRevert(
abi.encodeWithSelector(CredentialAudit.NotOperatorMaster.selector, attacker, master)
);
audit.appendRootV2(operatorOmni, root, bitmap, 1);
}

function test_CredentialAudit_V1_And_V2_Coexist() public {
// Both surfaces stay live during the migration cycle. The V1 emit
// path is observed today by the existing tier-A worker; V2 is
// what new emitters use. Confirm neither breaks the other.
bytes32 svc = keccak256("openrouter");
bytes32 payload = keccak256("blob-1");
audit.append(operatorOmni, actorOmniAgentA, svc, audit.OP_STORE(), payload);
assertEq(audit.entryCount(operatorOmni), 1);

bytes32 envHash = keccak256("v2-envelope");
audit.appendV2(operatorOmni, actorOmniAgentA, 0, envHash);
// V1 storage is untouched by V2 emits.
assertEq(audit.entryCount(operatorOmni), 1);
}

function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) {
// Internal-node prefix per codex M2.
return a < b
Expand Down
Loading
Loading