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
140 changes: 140 additions & 0 deletions assistant/src/daemon/approved-devices-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Persistent store for always-allowed paired devices.
*
* Persisted to ~/.vellum/protected/approved-devices.json using the
* atomic-write pattern from trust-store.ts (write .tmp → rename → chmod).
*/

import { existsSync, readFileSync, writeFileSync, renameSync, chmodSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { createHash } from 'node:crypto';
import { getRootDir } from '../util/platform.js';
import { getLogger } from '../util/logger.js';

const log = getLogger('approved-devices-store');

export interface ApprovedDevice {
hashedDeviceId: string;
deviceName: string;
lastPairedAt: number;
}

interface ApprovedDevicesFile {
version: 1;
devices: ApprovedDevice[];
}

function getStorePath(): string {
return join(getRootDir(), 'protected', 'approved-devices.json');
}

/** Hash a raw deviceId for storage. */
export function hashDeviceId(deviceId: string): string {
return createHash('sha256').update(deviceId).digest('hex');
}

let cachedDevices: Map<string, ApprovedDevice> | null = null;

function loadFromDisk(): Map<string, ApprovedDevice> {
const path = getStorePath();
if (!existsSync(path)) {
return new Map();
}
try {
const raw = readFileSync(path, 'utf-8');
const data = JSON.parse(raw) as ApprovedDevicesFile;
if (data.version !== 1 || !Array.isArray(data.devices)) {
log.warn('Invalid approved-devices.json format, starting fresh');
return new Map();
}
const map = new Map<string, ApprovedDevice>();
for (const device of data.devices) {
map.set(device.hashedDeviceId, device);
}
return map;
} catch (err) {
log.error({ err }, 'Failed to load approved-devices.json');
return new Map();
}
}

function saveToDisk(devices: Map<string, ApprovedDevice>): void {
const path = getStorePath();
const dir = dirname(path);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const data: ApprovedDevicesFile = {
version: 1,
devices: Array.from(devices.values()),
};
const tmpPath = path + '.tmp.' + process.pid;
writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 });
renameSync(tmpPath, path);
chmodSync(path, 0o600);
}

function getDevices(): Map<string, ApprovedDevice> {
if (cachedDevices == null) {
cachedDevices = loadFromDisk();
}
return cachedDevices;
}

/** Check if a hashed device ID is in the allowlist. */
export function isDeviceApproved(hashedDeviceId: string): boolean {
return getDevices().has(hashedDeviceId);
}

/** Add or update a device in the allowlist. */
export function approveDevice(hashedDeviceId: string, deviceName: string): void {
const devices = getDevices();
devices.set(hashedDeviceId, {
hashedDeviceId,
deviceName,
lastPairedAt: Date.now(),
});
saveToDisk(devices);
log.info({ hashedDeviceId }, 'Device approved and saved to allowlist');
}

/** Update lastPairedAt and deviceName for an existing device (auto-approve refresh). */
export function refreshDevice(hashedDeviceId: string, deviceName: string): void {
const devices = getDevices();
const existing = devices.get(hashedDeviceId);
if (existing) {
existing.deviceName = deviceName;
existing.lastPairedAt = Date.now();
saveToDisk(devices);
log.info({ hashedDeviceId }, 'Device metadata refreshed');
}
}

/** Remove a device from the allowlist. Returns true if removed. */
export function removeDevice(hashedDeviceId: string): boolean {
const devices = getDevices();
const removed = devices.delete(hashedDeviceId);
if (removed) {
saveToDisk(devices);
log.info({ hashedDeviceId }, 'Device removed from allowlist');
}
return removed;
}

/** Clear all approved devices. */
export function clearAllDevices(): void {
const devices = getDevices();
devices.clear();
saveToDisk(devices);
log.info('All approved devices cleared');
}

/** List all approved devices. */
export function listDevices(): ApprovedDevice[] {
return Array.from(getDevices().values());
}

/** Reset the in-memory cache (for testing). */
export function resetCache(): void {
cachedDevices = null;
}
2 changes: 2 additions & 0 deletions assistant/src/daemon/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { workspaceFileHandlers } from './workspace-files.js';
import { identityHandlers } from './identity.js';
import { dictationHandlers } from './dictation.js';
import { inboxInviteHandlers } from './config-inbox.js';
import { pairingHandlers } from './pairing.js';

// Re-export types and utilities for backwards compatibility
export type {
Expand Down Expand Up @@ -116,6 +117,7 @@ const handlers = {
...identityHandlers,
...dictationHandlers,
...inboxInviteHandlers,
...pairingHandlers,
...inlineHandlers,
} satisfies DispatchMap;

Expand Down
100 changes: 100 additions & 0 deletions assistant/src/daemon/handlers/pairing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as net from 'node:net';
import type {
PairingApprovalResponse,
ApprovedDeviceRemove,
} from '../ipc-protocol.js';
import { log, defineHandlers, type HandlerContext } from './shared.js';
import {
isDeviceApproved,
approveDevice,
refreshDevice,
removeDevice,
clearAllDevices,
listDevices,
} from '../approved-devices-store.js';
import type { PairingStore } from '../pairing-store.js';

/** Module-level reference set by the daemon server at startup. */
let pairingStoreRef: PairingStore | null = null;
let bearerTokenRef: string | undefined;

export function initPairingHandlers(store: PairingStore, bearerToken: string | undefined): void {
pairingStoreRef = store;
bearerTokenRef = bearerToken;
}

function handlePairingApprovalResponse(
msg: PairingApprovalResponse,
_socket: net.Socket,
ctx: HandlerContext,
): void {
if (!pairingStoreRef) {
log.warn('Pairing store not initialized');
return;
}

const entry = pairingStoreRef.get(msg.pairingRequestId);
if (!entry) {
log.warn({ pairingRequestId: msg.pairingRequestId }, 'Pairing request not found for approval response');
return;
}

// Idempotent: if already approved/denied, just re-broadcast the current status
if (entry.status === 'approved' || entry.status === 'denied') {
log.info({ pairingRequestId: msg.pairingRequestId, status: entry.status }, 'Duplicate approval response, no-op');
return;
}

if (msg.decision === 'deny') {
pairingStoreRef.deny(msg.pairingRequestId);
log.info({ pairingRequestId: msg.pairingRequestId }, 'Pairing request denied');
return;
}

// approve_once or always_allow
if (!bearerTokenRef) {
log.error('Cannot approve pairing: no bearer token configured');
return;
}

pairingStoreRef.approve(msg.pairingRequestId, bearerTokenRef);
log.info({ pairingRequestId: msg.pairingRequestId, decision: msg.decision }, 'Pairing request approved');

// If always_allow, persist the device to the allowlist
if (msg.decision === 'always_allow' && entry.hashedDeviceId) {
approveDevice(entry.hashedDeviceId, entry.deviceName ?? 'Unknown Device');
}
}

function handleApprovedDevicesList(socket: net.Socket, ctx: HandlerContext): void {
const devices = listDevices();
ctx.send(socket, {
type: 'approved_devices_list_response',
devices,
});
}

function handleApprovedDeviceRemove(
msg: ApprovedDeviceRemove,
socket: net.Socket,
ctx: HandlerContext,
): void {
const success = removeDevice(msg.hashedDeviceId);
ctx.send(socket, {
type: 'approved_device_remove_response',
success,
});
log.info({ hashedDeviceId: msg.hashedDeviceId, success }, 'Device removal requested via IPC');
}

function handleApprovedDevicesClear(_socket: net.Socket, _ctx: HandlerContext): void {
clearAllDevices();
log.info('All approved devices cleared via IPC');
}

export const pairingHandlers = defineHandlers({
pairing_approval_response: handlePairingApprovalResponse,
approved_devices_list: (_msg, socket, ctx) => handleApprovedDevicesList(socket, ctx),
approved_device_remove: handleApprovedDeviceRemove,
approved_devices_clear: (_msg, socket, ctx) => handleApprovedDevicesClear(socket, ctx),
});
Loading