feat(#755): add logical agent connector fencing

Add normalized runtime-neutral identity, durable PostgreSQL CAS leases, monotonic epochs, short-lived server grants, fail-closed adapter validation, credential-safe audit, and concurrency/restart/abuse coverage.\n\nRefs #755
This commit is contained in:
Jarvis
2026-07-14 13:04:52 -05:00
committed by Hermes Agent
parent 8599fd27d8
commit d190732a55
20 changed files with 6844 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import {
normalizeConnectorId,
normalizeConnectorScopes,
normalizeLeaseEpoch,
normalizeLogicalAgentIdentity,
normalizeLogicalBindingId,
type ConnectorExecutionContext,
type LogicalAgentIdentity,
} from './connector-lease.dto.js';
describe('logical agent connector lease contract', (): void => {
it('normalizes a runtime-neutral logical identity and binding vocabulary', (): void => {
const identity = normalizeLogicalAgentIdentity({
tenantId: ' tenant-01 ',
logicalAgentId: ' MOS.Primary ',
});
expect(identity).toEqual({ tenantId: 'tenant-01', logicalAgentId: 'mos.primary' });
expect(normalizeLogicalBindingId(' Discord:Operations ')).toBe('discord:operations');
expect(normalizeConnectorId(' PI.Worker-01 ')).toBe('pi.worker-01');
expect(Object.isFrozen(identity)).toBe(true);
expect(Object.keys(identity).sort()).toEqual(['logicalAgentId', 'tenantId']);
});
it('canonicalizes scopes and decimal fencing epochs', (): void => {
expect(normalizeConnectorScopes([' Runtime.Send ', 'tool.execute', 'runtime.send'])).toEqual([
'runtime.send',
'tool.execute',
]);
expect(normalizeLeaseEpoch('00042')).toBe('42');
});
it.each([
['', 'mos'],
['tenant', ''],
['tenant', 'claude session/123'],
])('rejects ambiguous identity values tenant=%j agent=%j', (tenantId, logicalAgentId): void => {
expect(() => normalizeLogicalAgentIdentity({ tenantId, logicalAgentId })).toThrow();
});
it('defines an adapter context without harness-native identity fields', (): void => {
const identity: LogicalAgentIdentity = { tenantId: 'tenant-01', logicalAgentId: 'mos' };
const context: ConnectorExecutionContext = {
identity,
bindingId: 'operator-chat',
connectorId: 'connector-a',
leaseId: '00000000-0000-4000-8000-000000000001',
leaseEpoch: '7',
scopes: ['runtime.send'],
correlationId: 'correlation-1',
grantExpiresAt: '2026-07-14T18:00:00.000Z',
};
expect(context).not.toHaveProperty('sessionId');
expect(context).not.toHaveProperty('tmuxSession');
expect(context).not.toHaveProperty('providerSessionId');
});
});

View File

@@ -0,0 +1,199 @@
const ID_PATTERN = /^[a-z0-9][a-z0-9._:@-]{0,127}$/;
const TENANT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/;
const SCOPE_PATTERN = /^[a-z][a-z0-9._:-]{0,127}$/;
/** Stable Mosaic identity. It intentionally contains no runtime/provider session identifier. */
export interface LogicalAgentIdentity {
readonly tenantId: string;
readonly logicalAgentId: string;
}
export interface LogicalAgentBinding {
readonly identity: LogicalAgentIdentity;
readonly bindingId: string;
}
export interface ConnectorLease extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly acquiredAt: string;
readonly heartbeatAt: string;
readonly expiresAt: string;
readonly releasedAt?: string;
}
export interface AcquireConnectorLeaseInput {
readonly identity: LogicalAgentIdentity;
readonly bindingId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly ttlMs: number;
readonly correlationId: string;
}
export interface TakeoverConnectorLeaseInput extends AcquireConnectorLeaseInput {
readonly expectedEpoch: string;
}
export interface HeartbeatConnectorLeaseInput {
readonly lease: ConnectorLease;
readonly ttlMs: number;
readonly correlationId: string;
}
export interface ReleaseConnectorLeaseInput {
readonly lease: ConnectorLease;
readonly correlationId: string;
}
export interface IssueConnectorExecutionGrantInput {
readonly lease: ConnectorLease;
readonly scopes: readonly string[];
readonly ttlMs: number;
readonly correlationId: string;
}
/** Internal server grant. Object provenance is checked in addition to these fields. */
export interface ConnectorExecutionGrant extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly issuedAt: string;
readonly expiresAt: string;
readonly correlationId: string;
}
/** Normalized context passed to an adapter only after current-lease validation. */
export interface ConnectorExecutionContext extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly correlationId: string;
readonly grantExpiresAt: string;
}
export interface FencedConnectorAdapter<TInput, TOutput> {
execute(input: TInput, context: ConnectorExecutionContext): Promise<TOutput>;
}
export type ConnectorLeaseAuditEventType =
| 'acquire'
| 'renew'
| 'takeover'
| 'reject'
| 'release'
| 'expiry';
export type ConnectorLeaseAuditOutcome = 'succeeded' | 'denied';
export type ConnectorLeaseRejectReason =
| 'policy_denied'
| 'lease_held'
| 'takeover_required'
| 'cas_mismatch'
| 'lease_missing'
| 'lease_released'
| 'lease_expired'
| 'stale_epoch'
| 'connector_mismatch'
| 'scope_denied'
| 'forged_grant'
| 'grant_expired';
/** Credential-safe metadata only: no grant object, scope set, payload, token, or approval ref. */
export interface ConnectorLeaseAuditEvent extends LogicalAgentBinding {
readonly event: ConnectorLeaseAuditEventType;
readonly outcome: ConnectorLeaseAuditOutcome;
readonly connectorId: string;
readonly correlationId: string;
readonly occurredAt: string;
readonly leaseId?: string;
readonly leaseEpoch?: string;
readonly reason?: ConnectorLeaseRejectReason;
}
export interface ConnectorLeaseAcquireMutation extends AcquireConnectorLeaseInput {
readonly leaseId: string;
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseTakeoverMutation extends TakeoverConnectorLeaseInput {
readonly leaseId: string;
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseHeartbeatMutation extends HeartbeatConnectorLeaseInput {
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseReleaseMutation extends ReleaseConnectorLeaseInput {
readonly now: string;
}
export interface ConnectorLeaseStore {
acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease>;
takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease>;
heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease>;
release(input: ConnectorLeaseReleaseMutation): Promise<void>;
findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null>;
recordAudit(event: ConnectorLeaseAuditEvent): Promise<void>;
}
export function normalizeLogicalAgentIdentity(input: LogicalAgentIdentity): LogicalAgentIdentity {
const tenantId = requiredIdentifier(input.tenantId, 'tenant ID', TENANT_PATTERN, false);
const logicalAgentId = requiredIdentifier(
input.logicalAgentId,
'logical agent ID',
ID_PATTERN,
true,
);
return Object.freeze({ tenantId, logicalAgentId });
}
export function normalizeLogicalBindingId(value: string): string {
return requiredIdentifier(value, 'logical binding ID', ID_PATTERN, true);
}
export function normalizeConnectorId(value: string): string {
return requiredIdentifier(value, 'connector ID', ID_PATTERN, true);
}
export function normalizeCorrelationId(value: string): string {
return requiredIdentifier(value, 'correlation ID', TENANT_PATTERN, false);
}
export function normalizeConnectorScope(value: string): string {
return requiredIdentifier(value, 'connector scope', SCOPE_PATTERN, true);
}
export function normalizeConnectorScopes(values: readonly string[]): readonly string[] {
if (values.length === 0) throw new Error('At least one connector scope is required');
return Object.freeze(Array.from(new Set(values.map(normalizeConnectorScope))).sort());
}
export function normalizeLeaseEpoch(value: string): string {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) throw new Error('Lease epoch must be a positive decimal integer');
const epoch = BigInt(trimmed);
if (epoch < 1n) throw new Error('Lease epoch must be a positive decimal integer');
return epoch.toString(10);
}
function requiredIdentifier(
value: string,
label: string,
pattern: RegExp,
lowerCase: boolean,
): string {
const trimmed = value.trim();
const normalized = lowerCase ? trimmed.toLowerCase() : trimmed;
if (!pattern.test(normalized)) {
throw new Error(`${label} has an invalid normalized format`);
}
return normalized;
}

View File

@@ -4,3 +4,4 @@ export interface AgentSessionHandle {
}
export * from './agent-runtime-provider.js';
export * from './connector-lease.dto.js';