refactor: scaffold module registries and default-module layout
Additive change — existing code paths still run via inline fallbacks. Prepares core for per-module extractions in PR #3 onward. Four registries added with empty defaults: - delivery action handlers (delivery.ts) - router inbound gate (router.ts) - response dispatcher (index.ts) - MCP tool self-registration (container/agent-runner/src/mcp-tools/server.ts) Default modules moved to src/modules/ for signaling: - src/modules/typing/ (extracted from delivery.ts) - src/modules/mount-security/ (moved from src/mount-security.ts) Both are imported directly by core — no hook, no registry. Removal requires editing core imports. Migrator now keys applied rows by name (uniqueness) so module migrations can pick arbitrary version numbers. Stored version column is auto-assigned as an applied-order sequence. sqlite_master guards added around core calls into module-owned tables (user_roles, agent_destinations, pending_questions). No-ops today; load-bearing after the owning modules are extracted. MODULE-HOOK markers placed at scheduling's two skill-edit sites (host-sweep.ts recurrence call, poll-loop.ts pre-task gate). PR #4 replaces the marked blocks when scheduling moves to its module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
src/modules/index.ts
Normal file
16
src/modules/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Modules barrel.
|
||||
*
|
||||
* Each module self-registers at import time. This barrel is imported by
|
||||
* src/index.ts for side effects (registry registrations, typing impl setup,
|
||||
* etc.). Core runs with an empty barrel — the registries have inline
|
||||
* fallbacks and `sqlite_master` guards.
|
||||
*
|
||||
* Default modules (ship with main, direct core import):
|
||||
* - src/modules/typing/ → imported directly by router/delivery/container-runner
|
||||
* - src/modules/mount-security/ → imported directly by container-runner
|
||||
*
|
||||
* Registry-based modules (installed via /add-<name> skills, pulled from the
|
||||
* `modules` branch): append imports below.
|
||||
*/
|
||||
export {};
|
||||
389
src/modules/mount-security/index.ts
Normal file
389
src/modules/mount-security/index.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Mount Security Module for NanoClaw
|
||||
*
|
||||
* Validates additional mounts against an allowlist stored OUTSIDE the project root.
|
||||
* This prevents container agents from modifying security configuration.
|
||||
*
|
||||
* Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { MOUNT_ALLOWLIST_PATH } from '../../config.js';
|
||||
import { log } from '../../log.js';
|
||||
|
||||
export interface AdditionalMount {
|
||||
hostPath: string;
|
||||
containerPath?: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export interface MountAllowlist {
|
||||
allowedRoots: AllowedRoot[];
|
||||
blockedPatterns: string[];
|
||||
}
|
||||
|
||||
export interface AllowedRoot {
|
||||
path: string;
|
||||
allowReadWrite: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Cache the allowlist in memory - only reloads on process restart
|
||||
let cachedAllowlist: MountAllowlist | null = null;
|
||||
let allowlistLoadError: string | null = null;
|
||||
|
||||
/**
|
||||
* Default blocked patterns - paths that should never be mounted
|
||||
*/
|
||||
const DEFAULT_BLOCKED_PATTERNS = [
|
||||
'.ssh',
|
||||
'.gnupg',
|
||||
'.gpg',
|
||||
'.aws',
|
||||
'.azure',
|
||||
'.gcloud',
|
||||
'.kube',
|
||||
'.docker',
|
||||
'credentials',
|
||||
'.env',
|
||||
'.netrc',
|
||||
'.npmrc',
|
||||
'.pypirc',
|
||||
'id_rsa',
|
||||
'id_ed25519',
|
||||
'private_key',
|
||||
'.secret',
|
||||
];
|
||||
|
||||
/**
|
||||
* Load the mount allowlist from the external config location.
|
||||
* Returns null if the file doesn't exist or is invalid.
|
||||
* Result is cached in memory for the lifetime of the process.
|
||||
*/
|
||||
export function loadMountAllowlist(): MountAllowlist | null {
|
||||
if (cachedAllowlist !== null) {
|
||||
return cachedAllowlist;
|
||||
}
|
||||
|
||||
if (allowlistLoadError !== null) {
|
||||
// Already tried and failed, don't spam logs
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
||||
// Do NOT cache this as an error — file may be created later without restart.
|
||||
// Only parse/structural errors are permanently cached.
|
||||
log.warn(
|
||||
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
|
||||
{ path: MOUNT_ALLOWLIST_PATH },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
|
||||
const allowlist = JSON.parse(content) as MountAllowlist;
|
||||
|
||||
// Validate structure
|
||||
if (!Array.isArray(allowlist.allowedRoots)) {
|
||||
throw new Error('allowedRoots must be an array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(allowlist.blockedPatterns)) {
|
||||
throw new Error('blockedPatterns must be an array');
|
||||
}
|
||||
|
||||
// Merge with default blocked patterns
|
||||
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
|
||||
allowlist.blockedPatterns = mergedBlockedPatterns;
|
||||
|
||||
cachedAllowlist = allowlist;
|
||||
log.info('Mount allowlist loaded successfully', {
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
allowedRoots: allowlist.allowedRoots.length,
|
||||
blockedPatterns: allowlist.blockedPatterns.length,
|
||||
});
|
||||
|
||||
return cachedAllowlist;
|
||||
} catch (err) {
|
||||
allowlistLoadError = err instanceof Error ? err.message : String(err);
|
||||
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
error: allowlistLoadError,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory and resolve to absolute path
|
||||
*/
|
||||
function expandPath(p: string): string {
|
||||
const homeDir = process.env.HOME || os.homedir();
|
||||
if (p.startsWith('~/')) {
|
||||
return path.join(homeDir, p.slice(2));
|
||||
}
|
||||
if (p === '~') {
|
||||
return homeDir;
|
||||
}
|
||||
return path.resolve(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the real path, resolving symlinks.
|
||||
* Returns null if the path doesn't exist.
|
||||
*/
|
||||
function getRealPath(p: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches any blocked pattern
|
||||
*/
|
||||
function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null {
|
||||
const pathParts = realPath.split(path.sep);
|
||||
|
||||
for (const pattern of blockedPatterns) {
|
||||
// Check if any path component matches the pattern
|
||||
for (const part of pathParts) {
|
||||
if (part === pattern || part.includes(pattern)) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if the full path contains the pattern
|
||||
if (realPath.includes(pattern)) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a real path is under an allowed root
|
||||
*/
|
||||
function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null {
|
||||
for (const root of allowedRoots) {
|
||||
const expandedRoot = expandPath(root.path);
|
||||
const realRoot = getRealPath(expandedRoot);
|
||||
|
||||
if (realRoot === null) {
|
||||
// Allowed root doesn't exist, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if realPath is under realRoot
|
||||
const relative = path.relative(realRoot, realPath);
|
||||
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the container path to prevent escaping /workspace/extra/
|
||||
*/
|
||||
function isValidContainerPath(containerPath: string): boolean {
|
||||
// Must not contain .. to prevent path traversal
|
||||
if (containerPath.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not be absolute (it will be prefixed with /workspace/extra/)
|
||||
if (containerPath.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not be empty
|
||||
if (!containerPath || containerPath.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw")
|
||||
if (containerPath.includes(':')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface MountValidationResult {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
realHostPath?: string;
|
||||
resolvedContainerPath?: string;
|
||||
effectiveReadonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single additional mount against the allowlist.
|
||||
* Returns validation result with reason.
|
||||
*/
|
||||
export function validateMount(mount: AdditionalMount): MountValidationResult {
|
||||
const allowlist = loadMountAllowlist();
|
||||
|
||||
// If no allowlist, block all additional mounts
|
||||
if (allowlist === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Derive containerPath from hostPath basename if not specified
|
||||
const containerPath = mount.containerPath || path.basename(mount.hostPath);
|
||||
|
||||
// Validate container path (cheap check)
|
||||
if (!isValidContainerPath(containerPath)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`,
|
||||
};
|
||||
}
|
||||
|
||||
// Expand and resolve the host path
|
||||
const expandedPath = expandPath(mount.hostPath);
|
||||
const realPath = getRealPath(expandedPath);
|
||||
|
||||
if (realPath === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check against blocked patterns
|
||||
const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns);
|
||||
if (blockedMatch !== null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if under an allowed root
|
||||
const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots);
|
||||
if (allowedRoot === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
|
||||
.map((r) => expandPath(r.path))
|
||||
.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine effective readonly status.
|
||||
// RW is only granted if the mount explicitly requests it AND the allowed
|
||||
// root permits it. Otherwise it's forced read-only.
|
||||
const requestedReadWrite = mount.readonly === false;
|
||||
let effectiveReadonly = true;
|
||||
|
||||
if (requestedReadWrite) {
|
||||
if (!allowedRoot.allowReadWrite) {
|
||||
log.info('Mount forced to read-only - root does not allow read-write', {
|
||||
mount: mount.hostPath,
|
||||
root: allowedRoot.path,
|
||||
});
|
||||
} else {
|
||||
effectiveReadonly = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
|
||||
realHostPath: realPath,
|
||||
resolvedContainerPath: containerPath,
|
||||
effectiveReadonly,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all additional mounts for a group.
|
||||
* Returns array of validated mounts (only those that passed validation).
|
||||
* Logs warnings for rejected mounts.
|
||||
*/
|
||||
export function validateAdditionalMounts(
|
||||
mounts: AdditionalMount[],
|
||||
groupName: string,
|
||||
): Array<{
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readonly: boolean;
|
||||
}> {
|
||||
const validatedMounts: Array<{
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readonly: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const mount of mounts) {
|
||||
const result = validateMount(mount);
|
||||
|
||||
if (result.allowed) {
|
||||
validatedMounts.push({
|
||||
hostPath: result.realHostPath!,
|
||||
containerPath: `/workspace/extra/${result.resolvedContainerPath}`,
|
||||
readonly: result.effectiveReadonly!,
|
||||
});
|
||||
|
||||
log.debug('Mount validated successfully', {
|
||||
group: groupName,
|
||||
hostPath: result.realHostPath,
|
||||
containerPath: result.resolvedContainerPath,
|
||||
readonly: result.effectiveReadonly,
|
||||
reason: result.reason,
|
||||
});
|
||||
} else {
|
||||
log.warn('Additional mount REJECTED', {
|
||||
group: groupName,
|
||||
requestedPath: mount.hostPath,
|
||||
containerPath: mount.containerPath,
|
||||
reason: result.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validatedMounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a template allowlist file for users to customize
|
||||
*/
|
||||
export function generateAllowlistTemplate(): string {
|
||||
const template: MountAllowlist = {
|
||||
allowedRoots: [
|
||||
{
|
||||
path: '~/projects',
|
||||
allowReadWrite: true,
|
||||
description: 'Development projects',
|
||||
},
|
||||
{
|
||||
path: '~/repos',
|
||||
allowReadWrite: true,
|
||||
description: 'Git repositories',
|
||||
},
|
||||
{
|
||||
path: '~/Documents/work',
|
||||
allowReadWrite: false,
|
||||
description: 'Work documents (read-only)',
|
||||
},
|
||||
],
|
||||
blockedPatterns: [
|
||||
// Additional patterns beyond defaults
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
],
|
||||
};
|
||||
|
||||
return JSON.stringify(template, null, 2);
|
||||
}
|
||||
165
src/modules/typing/index.ts
Normal file
165
src/modules/typing/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Typing indicator refresh — default module.
|
||||
*
|
||||
* Most platforms expire a typing indicator after 5–10s, so a one-shot
|
||||
* call on message arrival goes stale long before the agent finishes
|
||||
* thinking. This module keeps it alive by re-firing `setTyping` on a
|
||||
* short interval — but only while the agent is actually WORKING, gated
|
||||
* on the heartbeat file's mtime after an initial grace period.
|
||||
*
|
||||
* After delivering a user-facing message, the refresh is paused for
|
||||
* POST_DELIVERY_PAUSE_MS so the client-side indicator can visually
|
||||
* clear.
|
||||
*
|
||||
* Default module status:
|
||||
* - Lives in src/modules/ for signaling (not really core), but ships
|
||||
* on main and is imported directly by core. No registry, no hook.
|
||||
* - Removing requires editing src/router.ts, src/delivery.ts, and
|
||||
* src/container-runner.ts to drop the calls.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import { heartbeatPath } from '../../session-manager.js';
|
||||
|
||||
const TYPING_REFRESH_MS = 4000;
|
||||
/**
|
||||
* Grace window from startTypingRefresh: fire typing unconditionally
|
||||
* for this long regardless of heartbeat state. Covers container
|
||||
* spawn/wake latency (5–12s on cold start before first heartbeat).
|
||||
*/
|
||||
const TYPING_GRACE_MS = 15000;
|
||||
/**
|
||||
* After the grace window, a heartbeat must be mtimed within this
|
||||
* many ms of now to count as "agent is working." Heartbeats land
|
||||
* every few hundred ms during active work, so 6s is well above
|
||||
* the working floor and small enough to stop typing quickly when
|
||||
* the agent goes idle.
|
||||
*/
|
||||
const HEARTBEAT_FRESH_MS = 6000;
|
||||
/**
|
||||
* After we deliver a user-facing message, pause typing for this
|
||||
* long so the client-side indicator has time to visually clear.
|
||||
* Tuned for the longest common expiry (Discord ~10s). The interval
|
||||
* stays running; ticks inside the pause just skip the setTyping call.
|
||||
*/
|
||||
const POST_DELIVERY_PAUSE_MS = 10000;
|
||||
|
||||
interface TypingAdapter {
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
interface TypingTarget {
|
||||
agentGroupId: string;
|
||||
channelType: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
interval: NodeJS.Timeout;
|
||||
startedAt: number;
|
||||
pausedUntil: number; // epoch ms; 0 = not paused
|
||||
}
|
||||
|
||||
let adapter: TypingAdapter | null = null;
|
||||
const typingRefreshers = new Map<string, TypingTarget>();
|
||||
|
||||
/**
|
||||
* Bind the typing module to the channel delivery adapter so it can
|
||||
* call `setTyping`. Called once by `src/delivery.ts` inside
|
||||
* `setDeliveryAdapter`. Passing a fresh adapter replaces the prior
|
||||
* binding and leaves active refreshers in place (they'll use the
|
||||
* new adapter on their next tick).
|
||||
*/
|
||||
export function setTypingAdapter(a: TypingAdapter): void {
|
||||
adapter = a;
|
||||
}
|
||||
|
||||
async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
|
||||
try {
|
||||
await adapter?.setTyping?.(channelType, platformId, threadId);
|
||||
} catch {
|
||||
// Typing is best-effort — don't let it fail delivery or routing.
|
||||
}
|
||||
}
|
||||
|
||||
function isHeartbeatFresh(agentGroupId: string, sessionId: string): boolean {
|
||||
const hbPath = heartbeatPath(agentGroupId, sessionId);
|
||||
try {
|
||||
const stat = fs.statSync(hbPath);
|
||||
return Date.now() - stat.mtimeMs < HEARTBEAT_FRESH_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startTypingRefresh(
|
||||
sessionId: string,
|
||||
agentGroupId: string,
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
): void {
|
||||
const existing = typingRefreshers.get(sessionId);
|
||||
if (existing) {
|
||||
// Already refreshing. Fire an immediate tick for the new inbound
|
||||
// event and reset the grace window — the new message restarts
|
||||
// the container-wake latency budget. Also clear any lingering
|
||||
// post-delivery pause: a new inbound means the user expects
|
||||
// typing to show immediately.
|
||||
triggerTyping(channelType, platformId, threadId).catch(() => {});
|
||||
existing.startedAt = Date.now();
|
||||
existing.pausedUntil = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Immediate tick + periodic refresh.
|
||||
triggerTyping(channelType, platformId, threadId).catch(() => {});
|
||||
const startedAt = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const entry = typingRefreshers.get(sessionId);
|
||||
if (!entry) return; // stopped externally since this tick was scheduled
|
||||
|
||||
// Inside a post-delivery pause: skip setTyping but keep the
|
||||
// interval running so we resume automatically once the pause
|
||||
// expires.
|
||||
if (entry.pausedUntil > Date.now()) return;
|
||||
|
||||
const withinGrace = Date.now() - entry.startedAt < TYPING_GRACE_MS;
|
||||
if (withinGrace || isHeartbeatFresh(entry.agentGroupId, sessionId)) {
|
||||
triggerTyping(entry.channelType, entry.platformId, entry.threadId).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Out of grace AND heartbeat stale — agent is idle, stop refreshing.
|
||||
clearInterval(entry.interval);
|
||||
typingRefreshers.delete(sessionId);
|
||||
}, TYPING_REFRESH_MS);
|
||||
// unref so a stale refresher can't hold the event loop alive.
|
||||
interval.unref();
|
||||
typingRefreshers.set(sessionId, {
|
||||
agentGroupId,
|
||||
channelType,
|
||||
platformId,
|
||||
threadId,
|
||||
interval,
|
||||
startedAt,
|
||||
pausedUntil: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the typing refresh for POST_DELIVERY_PAUSE_MS. Called after
|
||||
* a user-facing message is delivered so the client-side indicator
|
||||
* has a chance to visually clear before the agent's next SDK event
|
||||
* pushes it back on. No-op if no refresh is active for this session.
|
||||
*/
|
||||
export function pauseTypingRefreshAfterDelivery(sessionId: string): void {
|
||||
const entry = typingRefreshers.get(sessionId);
|
||||
if (!entry) return;
|
||||
entry.pausedUntil = Date.now() + POST_DELIVERY_PAUSE_MS;
|
||||
}
|
||||
|
||||
export function stopTypingRefresh(sessionId: string): void {
|
||||
const entry = typingRefreshers.get(sessionId);
|
||||
if (!entry) return;
|
||||
clearInterval(entry.interval);
|
||||
typingRefreshers.delete(sessionId);
|
||||
}
|
||||
Reference in New Issue
Block a user