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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions api/claim/commandlayer-namespace.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,17 @@ function safeAgent(agent) {
canonicalParent: String(agent.canonicalParent || agent.canonical_parent || '').toLowerCase().trim(),
skill: String(agent.skill || '').trim(),
skillFamily: String(agent.skillFamily || agent.skill_family || '').trim(),
records: agent.records && typeof agent.records === 'object' && !Array.isArray(agent.records) ? JSON.parse(JSON.stringify(agent.records)) : undefined,
cardJson: sanitizedCard,
};
}

function recordsFromAgent(agent) {
const card = agent && agent.cardJson && typeof agent.cardJson === 'object' ? agent.cardJson : {};
const records = agent && agent.records && typeof agent.records === 'object' ? agent.records : card.records;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check card JSON signer before accepting managed agents

When a managed API client includes an agent.records object that omits cl.receipt.signer, this line stops looking at cardJson.records; the mismatched signer in cardJson is then persisted into claim_agents and can be pinned by api/admin/pin-agent-cards.js. For example, records: {} plus cardJson.records['cl.receipt.signer'] = 'acme.eth' returns 202 instead of managed_agent_signer_mismatch, so the new validation can be bypassed for the card records that downstream publication uses.

Useful? React with 👍 / 👎.

return records && typeof records === 'object' && !Array.isArray(records) ? records : {};
}

module.exports = async function handler(req, res) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
Expand Down Expand Up @@ -76,6 +83,19 @@ module.exports = async function handler(req, res) {
if (tenantSignerCanonicalization !== CANONICALIZATION) return invalid(res, 'invalid_canonicalization', `canonicalization must be ${CANONICALIZATION}.`);
if (!agents.length || agents.length > 10) return invalid(res, 'invalid_agents', 'At least one and at most ten generated agents are required.');
if (agents.some((a) => !ENS_RE.test(a.ens) || !a.capability || !a.canonicalParent || !a.skill || !a.skillFamily)) return invalid(res, 'invalid_agent', 'Every agent requires ens, capability, canonicalParent, skill, and skillFamily.');
if (activationMode === 'managed_namespace') {
const primaryManagedSignerEns = agents[0].ens;
if (tenantSignerEns !== primaryManagedSignerEns) {
return invalid(res, 'managed_signer_mismatch', 'Managed namespace tenantSignerEns must match the first generated managed agent ENS.');
Comment on lines +88 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the managed signer is a generated agent

This only checks that tenantSignerEns equals the first submitted agent ENS, so an API client can still submit a managed-namespace claim with both values set to the tenant root, e.g. tenantSignerEns: 'acme.eth' and agents[0].ens: 'acme.eth'. That passes the new guard, stores managed_ens_parent_namespace as eth, and reintroduces the root-signer managed claim that this change is meant to reject unless the code also verifies the ENS is the expected ${tenant}.${canonicalParent} managed agent.

Useful? React with 👍 / 👎.

}
const mismatchedAgent = agents.find((agent) => {
const recordSigner = recordsFromAgent(agent)['cl.receipt.signer'];
return recordSigner && String(recordSigner).toLowerCase().trim() !== agent.ens;
});
if (mismatchedAgent) {
return invalid(res, 'managed_agent_signer_mismatch', 'Managed namespace agent TXT records must use the agent ENS as cl.receipt.signer.');
}
}
if (!process.env.DATABASE_URL) return res.status(503).json({ ok: false, status: 'STORAGE_UNAVAILABLE' });

const claimId = `clm_${crypto.randomUUID().replace(/-/g, '')}`;
Expand All @@ -91,6 +111,7 @@ module.exports = async function handler(req, res) {
activationMode,
packId,
tenantSignerEns,
primaryTenantSignerEns: activationMode === 'managed_namespace' ? agents[0].ens : tenantSignerEns,
tenantSignerPublicKey,
tenantSignerKid,
tenantSignerCanonicalization,
Expand Down
14 changes: 9 additions & 5 deletions public/claim.html
Original file line number Diff line number Diff line change
Expand Up @@ -672,8 +672,11 @@ <h3>Activation pipeline</h3>
}

function getTenantSignerEns() {
const names = agentNames();
return (names[0] || state.ens || '').toLowerCase();
if (state.activationMode === 'cl') {
const names = agentNames();
return (names[0] || state.ens || '').toLowerCase();
Comment on lines +676 to +677

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive managed signer from effective canonical verbs

When a managed namespace is built by selecting/removing canonical parents directly, getEffectiveClVerbs() drives the submitted agents but this path still uses agentNames(), which reads getVerbs() from the pack/cherry state. If no pack is selected, or the canonical parent list no longer matches the pack, tenantSignerEns falls back to the tenant root or a different first agent while the payload's first agent comes from selectedCanonicalParents, and the new server-side managed_signer_mismatch check rejects the activation request.

Useful? React with 👍 / 👎.

}
return (state.ens || '').toLowerCase();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align BYO ENS records with the submitted signer

When activation mode is own, this now submits tenantSignerEns as the root state.ens, but buildENSRecords() still tells users to publish TXT records on each generated sub-agent with cl.receipt.signer=${name}. The status verifier resolves the stored claim.tenant_signer_ens and compares the four records there, so a BYO namespace user who follows Step 5 publishes records for e.g. approve.acme.eth while the claim verifies acme.eth, leaving activation stuck at records_unavailable/records_mismatch.

Useful? React with 👍 / 👎.

}

function signerTxtRecordsObject() {
Expand Down Expand Up @@ -1422,6 +1425,7 @@ <h3>Activation pipeline</h3>
key_model: 'single_ed25519_key_for_package',
kid: state.kid,
tenant_signer_ens: getTenantSignerEns(),
primary_tenant_signer_ens: getTenantSignerEns(),
public_key: `ed25519:${state.pubKeyB64}`,
tenant_signer_public_key: `ed25519:${state.pubKeyB64}`,
tenant_signer_kid: state.kid,
Expand Down Expand Up @@ -1469,8 +1473,9 @@ <h3>Activation pipeline</h3>
first_action_receipt: 'not_generated',
agent_live: 'not_live'
};
const claim = data && data.claim;
const rows = [
['1. Tenant signing identity generated locally', pipeline.tenant_signing_identity],
[`1. Signer ENS: ${claim && claim.tenant_signer_ens ? claim.tenant_signer_ens : getTenantSignerEns()}`, pipeline.tenant_signing_identity],
['2. Claim request created', pipeline.claim_request],
['3. Payment confirmed', pipeline.payment],
['4. ENS signer records verified', pipeline.ens_records],
Expand All @@ -1485,7 +1490,6 @@ <h3>Activation pipeline</h3>
const icon = cls === 'complete' ? '✅' : (cls === 'active' ? '⏳' : '○');
return `<div class="check-row ${cls === 'complete' ? 'done' : 'pending'}"><span class="check-icon">${icon}</span><div class="check-text"><strong>${label}</strong><span>${status || 'pending'}</span></div></div>`;
}).join('');
const claim = data && data.claim;
const firstAction = claim && (claim.first_action_receipt_status || 'not_generated');
const receiptNote = data && data.claim ? `<div class="check-row pending"><span class="check-icon">ℹ️</span><div class="check-text"><strong>First Action Receipt</strong><span>This proves the claimed agent can produce a scoped execution receipt. It does not prove payment settlement. Status: ${firstAction}</span></div></div>` : '';
if (receiptNote) el.insertAdjacentHTML('beforeend', receiptNote);
Expand All @@ -1494,7 +1498,7 @@ <h3>Activation pipeline</h3>
intro.textContent = claim.tenant_signer_record_status === 'records_verified'
? 'ENS signer identity verified on Ethereum mainnet'
: (claim.activation_mode === 'managed_namespace'
? 'Managed ENS publication automation is pending activation infrastructure.'
? 'Managed namespace mode signs receipts from the generated VerbAgent ENS, not the tenant root ENS.'
: 'Publish the four records with your ENS owner wallet, then verify records.');
}
const box = document.getElementById('verifyRecordsBox');
Expand Down
86 changes: 86 additions & 0 deletions tests/claim-activation-pipeline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,89 @@ test('public mutation endpoints have rate-limiting protection', async () => {
assert.equal(proof.statusCode, 429);
assert.equal(proof.body.status, 'RATE_LIMITED');
});

test('managed namespace package/recovery UI uses generated agent ENS as signer', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'claim.html'), 'utf8');
assert.ok(html.includes("if (state.activationMode === 'cl')"));
assert.ok(html.includes('primary_tenant_signer_ens: getTenantSignerEns()'));
assert.ok(html.includes("'cl.receipt.signer': agentName(v)"));
assert.ok(html.includes('tenant_signer_txt_package: signerTxtRecordPackage()'));
assert.ok(html.includes('tenant_signer_ens: result.tenantSignerEns || getTenantSignerEns()'));
assert.ok(html.includes('Signer ENS:'));
assert.ok(html.includes('Managed namespace mode signs receipts from the generated VerbAgent ENS, not the tenant root ENS.'));
});

test('managed namespace claim submission stores generated agent ENS and TXT signer', async () => {
resetRateLimitForTests();
process.env.DATABASE_URL = 'postgres://example';
const queries = [];
db.query = async (q, params) => { queries.push({ q, params }); return { rows: [], rowCount: 1 }; };
const res = makeRes();
await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.20' }, body: basePayload }, res);
assert.equal(res.statusCode, 202);
assert.equal(res.body.tenantSignerEns, 'acme.approveagent.eth');
const insert = queries.find((entry) => entry.q.includes('insert into claim_requests'));
assert.ok(insert);
assert.equal(insert.params[10], 'acme.approveagent.eth');
const txtRecords = JSON.parse(insert.params[15]);
assert.equal(txtRecords['cl.receipt.signer'], 'acme.approveagent.eth');
const requestJson = JSON.parse(insert.params[19]);
assert.equal(requestJson.tenantSignerEns, 'acme.approveagent.eth');
assert.equal(requestJson.primaryTenantSignerEns, 'acme.approveagent.eth');
});

test('managed namespace multiple capabilities uses first agent as primary while allowing per-agent signers', async () => {
resetRateLimitForTests();
process.env.DATABASE_URL = 'postgres://example';
const queries = [];
db.query = async (q, params) => { queries.push({ q, params }); return { rows: [], rowCount: 1 }; };
const payload = {
...basePayload,
tenantSignerEns: 'acme.approveagent.eth',
agents: [
{ ens: 'acme.approveagent.eth', capability: 'approve', canonicalParent: 'approveagent.eth', skill: 'trust-verification.approve', skillFamily: 'trust-verification', cardJson: { records: { 'cl.receipt.signer': 'acme.approveagent.eth' } } },
{ ens: 'acme.attestagent.eth', capability: 'attest', canonicalParent: 'attestagent.eth', skill: 'trust-verification.attest', skillFamily: 'trust-verification', cardJson: { records: { 'cl.receipt.signer': 'acme.attestagent.eth' } } },
],
};
const res = makeRes();
await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.21' }, body: payload }, res);
assert.equal(res.statusCode, 202);
assert.equal(res.body.tenantSignerEns, 'acme.approveagent.eth');
const insert = queries.find((entry) => entry.q.includes('insert into claim_requests'));
const requestJson = JSON.parse(insert.params[19]);
assert.equal(requestJson.primaryTenantSignerEns, 'acme.approveagent.eth');
assert.deepEqual(requestJson.agents.map((a) => a.ens), ['acme.approveagent.eth', 'acme.attestagent.eth']);
});

test('bring your own ENS mode can still store root signer ENS', async () => {
resetRateLimitForTests();
process.env.DATABASE_URL = 'postgres://example';
const queries = [];
db.query = async (q, params) => { queries.push({ q, params }); return { rows: [], rowCount: 1 }; };
const res = makeRes();
await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.22' }, body: { ...basePayload, activationMode: 'bring_your_own_ens', tenantSignerEns: 'acme.eth' } }, res);
assert.equal(res.statusCode, 202);
assert.equal(res.body.tenantSignerEns, 'acme.eth');
const insert = queries.find((entry) => entry.q.includes('insert into claim_requests'));
assert.equal(insert.params[10], 'acme.eth');
});

test('managed namespace mismatch between tenant signer and primary agent is rejected', async () => {
resetRateLimitForTests();
process.env.DATABASE_URL = 'postgres://example';
db.query = async () => { throw new Error('db should not be called'); };
const res = makeRes();
await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.23' }, body: { ...basePayload, tenantSignerEns: 'acme.eth' } }, res);
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'managed_signer_mismatch');
});

test('managed namespace agent cl.receipt.signer mismatch is rejected', async () => {
resetRateLimitForTests();
process.env.DATABASE_URL = 'postgres://example';
db.query = async () => { throw new Error('db should not be called'); };
const res = makeRes();
await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.24' }, body: { ...basePayload, agents: [{ ...basePayload.agents[0], cardJson: { records: { 'cl.receipt.signer': 'acme.eth' } } }] } }, res);
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'managed_agent_signer_mismatch');
});
Loading