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
71 changes: 71 additions & 0 deletions packages/agent-mesh/proto/registration.proto
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,74 @@ service AgentMeshIdentityService {
// Verify trust of a peer agent
rpc VerifyPeerTrust(TrustVerificationRequest) returns (TrustVerificationResponse);
}

// ─── Governance Service ───────────────────────────────────────────────────────
// Issue #558: Governance RPCs for policy evaluation, audit recording,
// and trust-score queries.

// PolicyRequest describes an action an agent wants to take,
// evaluated against the mesh-wide policy engine.
message PolicyRequest {
string agent_did = 1; // Requesting agent's DID
string action = 2; // Action to evaluate (e.g. "write:data")
string resource = 3; // Target resource
map<string, string> context = 4; // Additional context for policy evaluation
google.protobuf.Timestamp requested_at = 5;
}

// PolicyDecision is the governance engine's verdict.
message PolicyDecision {
bool allowed = 1; // Whether the action is permitted
string effect = 2; // "allow" | "deny" | "warn" | "require_approval"
string matched_rule = 3; // Name of the matching policy rule
string reason = 4; // Human-readable explanation
repeated string required_approvers = 5; // Approvers if effect is require_approval
google.protobuf.Timestamp evaluated_at = 6;
}

// AuditEntry records a governance event in the tamper-evident audit log.
message AuditEntry {
string agent_did = 1; // Agent that performed the action
string action = 2; // What was done
string decision = 3; // Policy decision that applied
string resource = 4; // Target resource
map<string, string> metadata = 5; // Arbitrary metadata
string hash = 6; // SHA-256 hash of this entry
string previous_hash = 7; // Hash of previous entry (chain)
google.protobuf.Timestamp timestamp = 8;
}

// AuditAck confirms that an audit entry was persisted.
message AuditAck {
bool accepted = 1; // Whether the entry was stored
string entry_id = 2; // Unique ID assigned to the entry
string hash = 3; // Confirmed hash
google.protobuf.Timestamp recorded_at = 4;
}

// TrustQuery asks for the current trust score of a specific agent.
message TrustQuery {
string agent_did = 1; // Agent to query
bool include_dimensions = 2; // Whether to include per-dimension breakdown
}

// TrustScoreResult returns the trust score and optional dimensional breakdown.
message TrustScoreResult {
string agent_did = 1; // Queried agent
int32 overall_score = 2; // 0-1000 overall trust score
string tier = 3; // "Untrusted" | "Provisional" | "Trusted" | "Verified"
TrustScoreDimensions dimensions = 4; // Per-dimension scores (if requested)
google.protobuf.Timestamp evaluated_at = 5;
}

// GovernanceService provides policy evaluation, audit recording, and trust queries.
service GovernanceService {
// Evaluate a proposed action against the policy engine.
rpc EvaluatePolicy (PolicyRequest) returns (PolicyDecision);

// Record an audit entry in the tamper-evident log.
rpc RecordAudit (AuditEntry) returns (AuditAck);

// Get the current trust score for an agent.
rpc GetTrustScore (TrustQuery) returns (TrustScoreResult);
}
39 changes: 37 additions & 2 deletions packages/agent-mesh/sdks/typescript/src/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,9 @@ export class AgentIdentity {

/** Reconstruct an AgentIdentity from its JSON representation. */
static fromJSON(json: AgentIdentityJSON): AgentIdentity {
const pubKey = new Uint8Array(Buffer.from(json.publicKey, 'base64'));
const pubKey = new Uint8Array(safeBase64Decode(json.publicKey));
const privKey = json.privateKey
? new Uint8Array(Buffer.from(json.privateKey, 'base64'))
? new Uint8Array(safeBase64Decode(json.privateKey))
: new Uint8Array(0);
return new AgentIdentity({
did: json.did,
Expand Down Expand Up @@ -461,6 +461,41 @@ export class IdentityRegistry {
}
}

// ── Key prefix stripping ──
// Ported from AzureClaw vendor SDK — keys serialized with type prefixes
// (e.g. "ed25519:<base64>") fail to decode without stripping the prefix first.

/**
* Strip a key type prefix (e.g. "ed25519:", "x25519:") before base64 decoding.
* Handles three cases:
* 1. Key starts with the expected prefix → strips it.
* 2. Key starts with a *different* known prefix → strips it with a warning.
* 3. Key has no prefix → returns as-is with a warning.
*/
export function stripKeyPrefix(keyStr: string, expectedPrefix: string): string {
if (keyStr.startsWith(expectedPrefix)) {
return keyStr.slice(expectedPrefix.length);
}
if (keyStr.includes(':')) {
const [, rest] = keyStr.split(':', 2);
console.warn(`Key has unexpected prefix, expected '${expectedPrefix}'`);
return rest ?? keyStr;
}
// No prefix at all — still valid, just not prefixed
return keyStr;
}

/**
* Safely decode a base64 key string that may carry a type prefix.
* Strips "ed25519:" / "x25519:" before decoding.
*/
export function safeBase64Decode(b64: string): Buffer {
let raw = b64;
if (raw.startsWith('ed25519:')) raw = raw.slice(8);
else if (raw.startsWith('x25519:')) raw = raw.slice(7);
return Buffer.from(raw, 'base64');
}

// ── Ed25519 DER prefixes ──

// SPKI prefix for Ed25519 (12 bytes) — OID 1.3.101.112
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-mesh/sdks/typescript/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
export { AgentIdentity, IdentityRegistry } from './identity';
export { AgentIdentity, IdentityRegistry, stripKeyPrefix, safeBase64Decode } from './identity';
export { TrustManager } from './trust';
export { PolicyEngine, PolicyConflictResolver } from './policy';
export type { PolicyDecision } from './policy';
Expand Down
116 changes: 116 additions & 0 deletions packages/agent-mesh/sdks/typescript/tests/key-prefix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { AgentIdentity, stripKeyPrefix, safeBase64Decode } from '../src/identity';

describe('stripKeyPrefix', () => {
it('strips the expected ed25519: prefix', () => {
const raw = 'AAAA';
expect(stripKeyPrefix(`ed25519:${raw}`, 'ed25519:')).toBe(raw);
});

it('strips the expected x25519: prefix', () => {
const raw = 'BBBB';
expect(stripKeyPrefix(`x25519:${raw}`, 'x25519:')).toBe(raw);
});

it('strips an unexpected prefix with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
const result = stripKeyPrefix('x25519:CCCC', 'ed25519:');
expect(result).toBe('CCCC');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("expected 'ed25519:'"),
);
warnSpy.mockRestore();
});

it('returns unprefixed keys as-is', () => {
expect(stripKeyPrefix('DDDD', 'ed25519:')).toBe('DDDD');
});

it('handles empty string', () => {
expect(stripKeyPrefix('', 'ed25519:')).toBe('');
});
});

describe('safeBase64Decode', () => {
const sampleBytes = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
const sampleB64 = sampleBytes.toString('base64'); // "3q2+7w=="

it('decodes plain base64', () => {
expect(safeBase64Decode(sampleB64)).toEqual(sampleBytes);
});

it('strips ed25519: prefix before decoding', () => {
expect(safeBase64Decode(`ed25519:${sampleB64}`)).toEqual(sampleBytes);
});

it('strips x25519: prefix before decoding', () => {
expect(safeBase64Decode(`x25519:${sampleB64}`)).toEqual(sampleBytes);
});

it('returns empty buffer for empty string', () => {
expect(safeBase64Decode('')).toEqual(Buffer.alloc(0));
});
});

describe('AgentIdentity.fromJSON with prefixed keys', () => {
let identity: AgentIdentity;
let json: ReturnType<AgentIdentity['toJSON']>;

beforeEach(() => {
identity = AgentIdentity.generate('prefix-test', ['read']);
json = identity.toJSON();
});

it('round-trips with plain (unprefixed) base64 keys', () => {
const restored = AgentIdentity.fromJSON(json);
expect(restored.did).toBe(identity.did);

const data = new TextEncoder().encode('round-trip');
const sig = restored.sign(data);
expect(restored.verify(data, sig)).toBe(true);
});

it('deserializes keys that carry an ed25519: prefix', () => {
// Simulate a serialized identity whose keys were stored with prefix
const prefixed = {
...json,
publicKey: `ed25519:${json.publicKey}`,
privateKey: json.privateKey ? `ed25519:${json.privateKey}` : undefined,
};

const restored = AgentIdentity.fromJSON(prefixed);
expect(restored.did).toBe(identity.did);

const data = new TextEncoder().encode('prefixed-key test');
const sig = restored.sign(data);
expect(restored.verify(data, sig)).toBe(true);
});

it('deserializes keys that carry an x25519: prefix', () => {
// x25519 prefix on ed25519 DER bytes is unusual but stripKeyPrefix
// handles "wrong prefix" gracefully.
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();

const prefixed = {
...json,
publicKey: `x25519:${json.publicKey}`,
privateKey: json.privateKey ? `x25519:${json.privateKey}` : undefined,
};

const restored = AgentIdentity.fromJSON(prefixed);
expect(restored.did).toBe(identity.did);

const data = new TextEncoder().encode('wrong-prefix test');
const sig = restored.sign(data);
expect(restored.verify(data, sig)).toBe(true);

warnSpy.mockRestore();
});

it('handles missing privateKey gracefully', () => {
const pubOnly = { ...json, privateKey: undefined };
const restored = AgentIdentity.fromJSON(pubOnly);
expect(restored.did).toBe(identity.did);
});
});
Loading