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
81 changes: 81 additions & 0 deletions src/__tests__/local-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,84 @@ describe('local-agent: emitBindingHint via reportAndSyncLocalAgent', () => {
expect(output).not.toContain('hookSpecificOutput');
});
});

describe('local-agent: security — install command hardening', () => {
async function runInstallCommand(command: Record<string, unknown>) {
await setupConfig();
const projectDir = path.join(tmpDir, 'sec-project');
await fse.ensureDir(projectDir);
const { execFileSync } = await import('node:child_process');
execFileSync('git', ['init'], { cwd: projectDir, stdio: 'ignore' });

const acks: Array<Record<string, unknown>> = [];
const fetchMock = vi.fn(async (url: string, init?: { body?: string }) => {
if (url.includes('/local-agent/sync')) {
return new Response(JSON.stringify({ ok: true, commands: [command] }));
}
if (url.includes('/commands/ack')) {
acks.push(JSON.parse(init?.body ?? '{}'));
}
return new Response(JSON.stringify({ ok: true }));
});
vi.stubGlobal('fetch', fetchMock);

const { reportAndSyncLocalAgent } = await import('../local-agent.js');
await reportAndSyncLocalAgent({ cwd: projectDir, tool: 'claude', status: 'running' });
return acks;
}

it('rejects a path-traversal slug and acks failed without escaping the repo', async () => {
const acks = await runInstallCommand({
id: 1,
type: 'install_rule',
rule_slug: '../../evil',
download_url: 'https://test.example.com/evil.md',
});

// The malicious command must be reported as failed with the guard message.
expect(acks).toHaveLength(1);
expect(acks[0].status).toBe('failed');
expect(String(acks[0].error)).toContain('Invalid resource slug');

// Nothing must have been written outside the resource repo.
await expect(fse.pathExists(path.join(tmpDir, '.teamai', 'evil.md'))).resolves.toBe(false);
await expect(fse.pathExists(path.join(tmpDir, 'evil.md'))).resolves.toBe(false);
});

it('rejects a file:// download_url (SSRF / arbitrary local file read)', async () => {
const acks = await runInstallCommand({
id: 2,
type: 'install_rule',
rule_slug: 'legit-rule',
download_url: 'file:///etc/passwd',
});

expect(acks).toHaveLength(1);
expect(acks[0].status).toBe('failed');
expect(String(acks[0].error)).toContain('Unsupported download URL scheme');
});
});

describe('local-agent: security — token file permissions', () => {
it('writes the credential token with owner-only (0o600) permissions', async () => {
const { writeTokenFile } = await import('../local-agent.js');
const tokenPath = path.join(tmpDir, 'token');
await writeTokenFile(tokenPath, 'secret-token-abc');

expect(await fse.pathExists(tokenPath)).toBe(true);
expect(await fse.readFile(tokenPath, 'utf-8')).toBe('secret-token-abc\n');
const mode = (await fse.stat(tokenPath)).mode & 0o777;
expect(mode).toBe(0o600);
});

it('tightens permissions on an already-existing token file', async () => {
const { writeTokenFile } = await import('../local-agent.js');
const tokenPath = path.join(tmpDir, 'token');
// Pre-create with world-readable perms to prove chmod tightens it.
await fse.writeFile(tokenPath, 'old\n', { mode: 0o644 });
await writeTokenFile(tokenPath, 'new-token');

const mode = (await fse.stat(tokenPath)).mode & 0o777;
expect(mode).toBe(0o600);
});
});
116 changes: 93 additions & 23 deletions src/local-agent.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import readline from 'node:readline';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import fse from 'fs-extra';
import YAML from 'yaml';
import { log } from './utils/logger.js';
Expand All @@ -23,6 +21,7 @@ import { RulesHandler, SkillsHandler } from './resources/index.js';
import { injectHooksToAllTools } from './hooks.js';
import { parseHookEvent, appendEvent, compactEvents } from './dashboard-collector.js';
import { getCurrentVersion } from './package-info.js';
import { getMachineId, deriveLocalAgentId } from './machine-id.js';
import {
TEAMAI_HOME,
TEAMAI_TOKEN_PATH,
Expand Down Expand Up @@ -55,7 +54,12 @@ interface WorkspaceBinding {
export interface LocalAgentConfig {
endpoint: string;
token?: string;
localAgentId: string;
/**
* @deprecated No longer the id source. local_agent_id is now derived at
* runtime per detected tool via resolveLocalAgentId(). Kept optional so
* older config.json files still load without a rewrite.
*/
localAgentId?: string;
createdAt: string;
userGroupId?: number;
userGroupName?: string;
Expand Down Expand Up @@ -151,8 +155,21 @@ function normalizeEndpoint(endpoint: string): string {
return endpoint.trim().replace(/\/+$/, '');
}

function generateLocalAgentId(): string {
return crypto.randomBytes(8).toString('hex');
/**
* Resolve the local_agent_id for the current invocation.
*
* Deterministic per (detected tool + machine + install dir) — same tool on the
* same machine always yields the same id, so the backend sees a stable agent
* instead of a fresh random one every hook fire. The tool is auto-detected from
* the hook's --tool flag (context.tool); different tools (claude / codebuddy /
* workbuddy) get different ids by design. TEAMAI_LOCAL_AGENT_ID still overrides
* for explicit pinning.
*/
function resolveLocalAgentId(context: LocalAgentContext): string {
const envOverride = process.env.TEAMAI_LOCAL_AGENT_ID;
if (envOverride) return envOverride;
const agentType = context.tool ?? 'workbuddy';
return deriveLocalAgentId(agentType, getMachineId(), getLocalAgentHome());
}

function scopeKey(scope: LocalAgentScope, workspacePath?: string): string {
Expand Down Expand Up @@ -184,7 +201,7 @@ function getManifestScope(

export async function loadLocalAgentConfig(): Promise<LocalAgentConfig | null> {
const fileConfig = await readJson<LocalAgentConfig>(getConfigPath());
if (fileConfig?.endpoint && fileConfig.localAgentId) {
if (fileConfig?.endpoint) {
return {
...fileConfig,
endpoint: normalizeEndpoint(fileConfig.endpoint),
Expand All @@ -201,7 +218,6 @@ export async function loadLocalAgentConfig(): Promise<LocalAgentConfig | null> {
return {
endpoint: normalizeEndpoint(envEndpoint),
token: process.env.TEAMAI_API_TOKEN ?? process.env.TEAMAI_TOKEN,
localAgentId: process.env.TEAMAI_LOCAL_AGENT_ID ?? generateLocalAgentId(),
createdAt: new Date().toISOString(),
workspaceBindings: {},
};
Expand Down Expand Up @@ -536,7 +552,7 @@ export async function buildReportPayload(
const payload: Record<string, unknown> = {
agent_type: context.tool ?? 'workbuddy',
agent_version: getCurrentVersion(),
local_agent_id: config.localAgentId,
local_agent_id: resolveLocalAgentId(context),
host_name: os.hostname(),
os: os.platform(),
started_at: config.createdAt,
Expand Down Expand Up @@ -577,7 +593,7 @@ async function buildSyncPayload(
const binding = workspacePath ? config.workspaceBindings[workspacePath] : undefined;
const payload: Record<string, unknown> = {
agent_type: context.tool ?? 'workbuddy',
local_agent_id: config.localAgentId,
local_agent_id: resolveLocalAgentId(context),
status: context.status ?? 'running',
};
if (workspacePath) {
Expand Down Expand Up @@ -610,6 +626,24 @@ function commandAction(command: LocalAgentCommand): 'install' | 'uninstall' | nu
return null;
}

/**
* Reject slugs that could escape the resource directory. Slugs come from
* backend sync commands and are used directly in filesystem paths, so a value
* like `../../.ssh/authorized_keys` would otherwise write outside the repo.
*/
function validateSlug(slug: string): string {
if (
!slug ||
slug.includes('/') ||
slug.includes('\\') ||
slug.includes('..') ||
path.isAbsolute(slug)
) {
throw new Error(`Invalid resource slug: ${slug}`);
}
return slug;
}

function commandSlug(command: LocalAgentCommand, kind: CommandResourceKind): string {
const slug =
kind === 'skill' ? command.skill_slug :
Expand All @@ -619,7 +653,7 @@ function commandSlug(command: LocalAgentCommand, kind: CommandResourceKind): str
if (!resolved) {
throw new Error(`Missing ${kind} slug`);
}
return resolved;
return validateSlug(resolved);
}

function commandVersion(command: LocalAgentCommand, kind: CommandResourceKind): string | undefined {
Expand All @@ -634,21 +668,48 @@ function manifestKind(kind: CommandResourceKind): ResourceKind {
return kind === 'skill' ? 'skills' : kind === 'rule' ? 'rules' : 'claudemd';
}

/** Only http(s) downloads are allowed — reject file:, ftp:, gopher:, etc. */
function assertHttpUrl(rawUrl: string): URL {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid download URL: ${rawUrl}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Unsupported download URL scheme: ${parsed.protocol}`);
}
return parsed;
}

/**
* Fetch a resource by URL. download_url comes from backend sync commands, so it
* is treated as untrusted: only http(s) is honoured (no file:// / local-path
* copy, which would be arbitrary local file read), and redirects are followed
* manually so every hop's scheme is re-validated instead of blindly trusting
* whatever Location the server returns.
*/
async function downloadResource(downloadUrl: string): Promise<string> {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'teamai-local-agent-'));
const filePath = path.join(tmpDir, 'resource');

if (downloadUrl.startsWith('file://')) {
await fse.copyFile(fileURLToPath(downloadUrl), filePath);
return filePath;
}

if (path.isAbsolute(downloadUrl) && await pathExists(downloadUrl)) {
await fse.copyFile(downloadUrl, filePath);
return filePath;
let current = assertHttpUrl(downloadUrl);
let response: Response;
const maxRedirects = 5;
for (let hop = 0; ; hop++) {
response = await fetch(current, { redirect: 'manual' });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) break;
if (hop >= maxRedirects) {
throw new Error(`Download failed: too many redirects (${downloadUrl})`);
}
current = assertHttpUrl(new URL(location, current).toString());
continue;
}
break;
}

const response = await fetch(downloadUrl, { redirect: 'follow' });
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}
Expand Down Expand Up @@ -935,7 +996,7 @@ async function processCommands(
commands: LocalAgentCommand[],
context: LocalAgentContext,
): Promise<void> {
const tag = `[${config.localAgentId.slice(-6)}] [${context.tool}]`;
const tag = `[${resolveLocalAgentId(context).slice(-6)}] [${context.tool}]`;
for (const command of commands) {
try {
const version = await executeCommand(config, command, context);
Expand Down Expand Up @@ -965,7 +1026,7 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi
await emitBindingHint(config, workspacePath);
}

const tag = `[${config.localAgentId.slice(-6)}] [${context.tool}]`;
const tag = `[${resolveLocalAgentId(context).slice(-6)}] [${context.tool}]`;
log.debug(`${tag} run: endpoint=${config.endpoint}`);

try {
Expand Down Expand Up @@ -1056,6 +1117,16 @@ export async function hookDispatch(eventName: string, tool?: string): Promise<vo
});
}

/**
* Persist the API token as a credential file with owner-only (0o600)
* permissions. chmod after write so an already-existing token file (whose perms
* mode-on-create would not touch) is also tightened.
*/
export async function writeTokenFile(tokenPath: string, token: string): Promise<void> {
await fs.promises.writeFile(tokenPath, token + '\n', { mode: 0o600 });
await fs.promises.chmod(tokenPath, 0o600);
}

export async function initLocalAgentHttp(options: {
endpoint: string;
token?: string;
Expand All @@ -1074,7 +1145,6 @@ export async function initLocalAgentHttp(options: {
const config: LocalAgentConfig = {
endpoint,
token: options.token,
localAgentId: existing?.localAgentId ?? generateLocalAgentId(),
createdAt: existing?.createdAt ?? new Date().toISOString(),
workspaceBindings: existing?.workspaceBindings ?? {},
userGroupId: existing?.userGroupId,
Expand All @@ -1084,7 +1154,7 @@ export async function initLocalAgentHttp(options: {
await ensureDir(getLocalAgentHome());
await saveLocalAgentConfig(config);
if (options.token) {
await writeFile(TEAMAI_TOKEN_PATH, options.token + '\n');
await writeTokenFile(TEAMAI_TOKEN_PATH, options.token);
}

const teamConfig = createLocalAgentTeamConfig(endpoint);
Expand Down
Loading