Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dist/

# Local agent/harness config — never commit (may contain target hosts)
.claude/
/config.json

# Local agent state & scan output — never commit. Anchored to the repo
# root so they don't also exclude source dirs like src/findings/.
Expand Down
28 changes: 17 additions & 11 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,16 @@ async function main(): Promise<number> {
process.on('SIGTERM', () => onSig('SIGTERM'));
process.on('SIGHUP', () => onSig('SIGHUP'));

// Config.
// Config — required on every launch; see ~/.pentesterflow/config.json or ./config.json.
let cfg: config.Config;
try {
cfg = config.load();
} catch (err) {
const badPath = config.configPath();
if (err instanceof config.ConfigNotFoundError) {
process.stderr.write(`${err.formatMessage()}\n`);
return 1;
}
const badPath = config.loadedConfigPath() ?? config.canonicalConfigPath();
const backupPath = `${badPath}.bad-${Date.now()}`;
try {
renameSync(badPath, backupPath);
Expand All @@ -238,6 +242,9 @@ async function main(): Promise<number> {
}
cfg = config.defaultConfig();
}
if (config.loadedConfigPath()) {
process.stderr.write(`config: ${config.loadedConfigPath()}\n`);
}
if (flags.backend) cfg = { ...cfg, backend: flags.backend as config.Config['backend'] };
if (flags.model) cfg.model = flags.model;
if (flags.baseURL) cfg.base_url = flags.baseURL;
Expand Down Expand Up @@ -601,7 +608,7 @@ async function main(): Promise<number> {
const bannerData: BannerData = {
provider: providerLabel(cfg.backend),
model: client.model() || cfg.model || '(unset)',
endpoint: cfg.base_url || defaultEndpoint(cfg.backend),
endpoint: cfg.base_url || defaultEndpoint(cfg, cfg.backend),
state: localityFor(cfg.backend),
status: `Session ${sessionID.slice(0, 8)} — type /help to begin`,
cwd: prettyCwd(),
Expand Down Expand Up @@ -634,7 +641,7 @@ async function main(): Promise<number> {
const ctxP =
cfg.backend === 'ollama' || cfg.backend === ''
? detectOllamaContextWindow(
cfg.base_url || defaultEndpoint(cfg.backend),
config.resolveBackendBaseUrl(cfg, 'ollama'),
agent.client.model(),
signal,
).then((info) => {
Expand Down Expand Up @@ -691,6 +698,7 @@ async function main(): Promise<number> {
baseURL: cfg.base_url,
apiKey: cfg.api_key,
model: cfg.model,
backendBaseURL: (backend) => config.resolveBackendBaseUrl(cfg, backend),
}),
persistDisabledSkills: async (names: string[]) => {
cfg.disabled_skills = [...names].sort();
Expand All @@ -711,7 +719,7 @@ async function main(): Promise<number> {
bannerHolder.publish?.({
provider: providerLabel(cfg.backend),
model: next.model() || cfg.model || '(unset)',
endpoint: cfg.base_url || defaultEndpoint(cfg.backend),
endpoint: cfg.base_url || defaultEndpoint(cfg, cfg.backend),
state: localityFor(cfg.backend),
});
void runProbes(rootCtl.signal);
Expand Down Expand Up @@ -786,13 +794,11 @@ function localityFor(b: string): string {
: 'local';
}

function defaultEndpoint(b: string): string {
function defaultEndpoint(cfg: config.Config, b: string): string {
if (b === 'ollama' || b === '' || b === 'lmstudio') {
return config.resolveBackendBaseUrl(cfg, b as config.Backend);
}
switch (b) {
case 'ollama':
case '':
return 'http://localhost:11434';
case 'lmstudio':
return 'http://localhost:1234/v1';
case 'kimi':
return KIMI_DEFAULT_BASE_URL;
case 'groq':
Expand Down
80 changes: 76 additions & 4 deletions src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,28 @@
// rejection set matches and that a clean config round-trips through save
// and load.

import { mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { defaultConfig, load, save } from './config.js';
import {
ConfigNotFoundError,
DEFAULT_LMSTUDIO_BASE_URL,
DEFAULT_OLLAMA_BASE_URL,
configSearchPaths,
defaultConfig,
load,
resolveBackendBaseUrl,
save,
} from './config.js';

let tmp = '';
const originalEnv = process.env.PENTESTERFLOW_CONFIG;

beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'pf-config-'));
process.env.PENTESTERFLOW_CONFIG = join(tmp, 'config.json');
writeFileSync(process.env.PENTESTERFLOW_CONFIG, '{}\n', 'utf8');
});

afterEach(() => {
Expand All @@ -26,10 +36,72 @@ afterEach(() => {
});

describe('config', () => {
it('returns a default config when the file is missing', () => {
it('throws a helpful error when the config file is missing', () => {
rmSync(process.env.PENTESTERFLOW_CONFIG ?? '', { force: true });
try {
load();
throw new Error('expected load() to throw');
} catch (err) {
expect(err).toBeInstanceOf(ConfigNotFoundError);
const msg = (err as ConfigNotFoundError).formatMessage();
expect(msg).toContain('config file not found');
expect(msg).toContain('ollama_base_url');
expect(msg).toContain(process.env.PENTESTERFLOW_CONFIG ?? '');
}
});

it('fills schema defaults from an empty config file', () => {
const cfg = load();
expect(cfg.backend).toBe('');
expect(cfg.mcp_servers).toEqual([]);
expect(cfg.ollama_base_url).toBe(DEFAULT_OLLAMA_BASE_URL);
expect(cfg.lmstudio_base_url).toBe(DEFAULT_LMSTUDIO_BASE_URL);
});

it('prefers project-local config.json over the user-global path', () => {
const prev = process.env.PENTESTERFLOW_CONFIG;
process.env.PENTESTERFLOW_CONFIG = '';
const root = mkdtempSync(join(tmpdir(), 'pf-config-root-'));
const project = join(root, 'config.json');
writeFileSync(project, JSON.stringify({ backend: 'lmstudio', model: 'local-model' }));
try {
const paths = configSearchPaths(root);
expect(paths[1]).toBe(project);
const originalCwd = process.cwd();
process.chdir(root);
try {
const cfg = load();
expect(cfg.backend).toBe('lmstudio');
expect(cfg.model).toBe('local-model');
} finally {
process.chdir(originalCwd);
}
} finally {
rmSync(root, { recursive: true, force: true });
if (prev === undefined) process.env.PENTESTERFLOW_CONFIG = undefined;
else process.env.PENTESTERFLOW_CONFIG = prev;
writeFileSync(join(tmp, 'config.json'), '{}\n', 'utf8');
}
});

it('persists custom local backend URLs and resolves them', async () => {
const cfg = defaultConfig();
cfg.backend = 'ollama';
cfg.ollama_base_url = 'http://192.168.1.10:11434';
cfg.lmstudio_base_url = 'http://192.168.1.10:1234/v1';
await save(cfg);
const reloaded = load();
expect(reloaded.ollama_base_url).toBe('http://192.168.1.10:11434');
expect(resolveBackendBaseUrl(reloaded, 'ollama')).toBe('http://192.168.1.10:11434');
expect(resolveBackendBaseUrl(reloaded, 'lmstudio')).toBe('http://192.168.1.10:1234/v1');
});

it('prefers base_url override for the active backend', () => {
const cfg = defaultConfig();
cfg.backend = 'ollama';
cfg.base_url = 'http://custom:11434';
expect(resolveBackendBaseUrl(cfg, 'ollama')).toBe('http://custom:11434');
expect(resolveBackendBaseUrl(cfg, 'lmstudio')).toBe(DEFAULT_LMSTUDIO_BASE_URL);
});

it('round-trips through save and load', async () => {
Expand Down Expand Up @@ -124,7 +196,7 @@ describe('config', () => {
});

it('leaves tooling_profile undefined when never set (signals first run)', () => {
const cfg = load(); // file doesn't exist → defaults
const cfg = load();
expect(cfg.tooling_profile).toBeUndefined();
});

Expand Down
126 changes: 116 additions & 10 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { chmod, open, rename, unlink } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { z } from 'zod';

// ---------- Schema ----------
Expand Down Expand Up @@ -53,10 +53,19 @@ export type ToolingProfile = z.infer<typeof ToolingProfile>;
* default" and size the threshold to the model's real context window. */
export const DEFAULT_AUTO_COMPACT_THRESHOLD = 16000;

/** Default Ollama API base when not overridden in ~/.pentesterflow/config.json. */
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
/** Default LM Studio OpenAI-compatible base when not overridden in config. */
export const DEFAULT_LMSTUDIO_BASE_URL = 'http://localhost:1234/v1';

const ConfigSchema = z.object({
backend: Backend.default(''),
model: z.string().default(''),
base_url: z.string().default(''),
// Per-backend local endpoint defaults. Used when base_url is empty or when
// listing models for a backend other than the active one.
ollama_base_url: z.string().default(DEFAULT_OLLAMA_BASE_URL),
lmstudio_base_url: z.string().default(DEFAULT_LMSTUDIO_BASE_URL),
api_key: z.string().default(''),
skills_dirs: z.array(z.string()).default([]),
// Skill names the user has disabled via /skills. Hidden from the system
Expand Down Expand Up @@ -104,20 +113,95 @@ function noShellMeta(s: string): boolean {

// ---------- Paths ----------

export function configPath(): string {
/** User-global config path (recommended for installed binaries). */
export function canonicalConfigPath(): string {
const override = process.env.PENTESTERFLOW_CONFIG;
if (override && override.length > 0) return override;
const home = homedir();
return join(home, '.pentesterflow', 'config.json');
if (override && override.length > 0) return resolve(override);
return join(homedir(), '.pentesterflow', 'config.json');
}

/** Paths checked in order when PENTESTERFLOW_CONFIG is not set. */
export function configSearchPaths(cwd = process.cwd()): string[] {
return [
join(cwd, '.pentesterflow', 'config.json'),
join(cwd, 'config.json'),
join(homedir(), '.pentesterflow', 'config.json'),
];
}

/** @deprecated Use canonicalConfigPath() or loadedConfigPath(). */
export function configPath(): string {
return loadedConfigPath() ?? canonicalConfigPath();
}

let activeConfigPath: string | undefined;

/** Absolute path of the config file used by the current process, if load() succeeded. */
export function loadedConfigPath(): string | undefined {
return activeConfigPath;
}

export class ConfigNotFoundError extends Error {
readonly searched: readonly string[];

constructor(searched: readonly string[]) {
super('config file not found');
this.name = 'ConfigNotFoundError';
this.searched = searched;
}

formatMessage(): string {
const recommended = join(homedir(), '.pentesterflow', 'config.json');
return [
'pentesterflow: config file not found.',
'',
'PentesterFlow needs a config.json with your LLM settings (backend, model, endpoints).',
'',
'Searched:',
...this.searched.map((p) => ` - ${p}`),
'',
`Recommended location (persists across installs): ${recommended}`,
'',
'Example config.json:',
'{',
' "backend": "ollama",',
' "model": "qwen2.5-coder:32b",',
' "ollama_base_url": "http://localhost:11434",',
' "lmstudio_base_url": "http://localhost:1234/v1"',
'}',
'',
'Or point at a specific file:',
' PENTESTERFLOW_CONFIG=C:\\path\\to\\config.json pentesterflow',
].join('\n');
}
}

function resolveConfigLoadPath(): string {
const envOverride = process.env.PENTESTERFLOW_CONFIG;
if (envOverride && envOverride.length > 0) {
const path = resolve(envOverride);
if (!existsSync(path)) {
throw new ConfigNotFoundError([path]);
}
return path;
}
const searched = configSearchPaths();
for (const path of searched) {
if (existsSync(path)) return path;
}
throw new ConfigNotFoundError(searched);
}

function configSavePath(): string {
if (activeConfigPath) return activeConfigPath;
return canonicalConfigPath();
}

// ---------- Load / save ----------

export function load(): Config {
const path = configPath();
if (!existsSync(path)) {
return ConfigSchema.parse({});
}
const path = resolveConfigLoadPath();
activeConfigPath = path;
let raw: unknown;
try {
const buf = readFileSync(path, 'utf8');
Expand All @@ -138,7 +222,8 @@ export function load(): Config {
* rename. Cleans up the .tmp on any error path.
*/
export async function save(cfg: Config): Promise<void> {
const path = configPath();
const path = configSavePath();
activeConfigPath = path;
const dir = dirname(path);
mkdirSync(dir, { recursive: true, mode: 0o700 });

Expand Down Expand Up @@ -184,3 +269,24 @@ function stringifyError(err: unknown): string {
export function defaultConfig(): Config {
return ConfigSchema.parse({});
}

/**
* Resolve the effective API base URL for a backend.
* `base_url` overrides the active backend only; other backends use their
* dedicated config fields (ollama_base_url / lmstudio_base_url).
*/
export function resolveBackendBaseUrl(cfg: Config, backend?: Backend): string {
const b: Exclude<Backend, ''> =
backend === undefined || backend === '' ? (cfg.backend === '' ? 'ollama' : cfg.backend) : backend;
const isActive =
b === cfg.backend || (b === 'ollama' && (cfg.backend === '' || cfg.backend === 'ollama'));
if (isActive && cfg.base_url) return cfg.base_url;
switch (b) {
case 'ollama':
return cfg.ollama_base_url;
case 'lmstudio':
return cfg.lmstudio_base_url;
default:
return cfg.base_url;
}
}
11 changes: 11 additions & 0 deletions src/llm/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,15 @@ describe('newFromConfig', () => {

expect(() => newFromConfig(cfg)).toThrow(/GEMINI_API_KEY/);
});

it('uses ollama_base_url from config when base_url is empty', () => {
const cfg = defaultConfig();
cfg.backend = 'ollama';
cfg.ollama_base_url = 'http://192.168.1.5:11434';

const client = newFromConfig(cfg);

expect(client.name()).toBe('ollama');
expect((client as { baseURL: string }).baseURL).toBe('http://192.168.1.5:11434');
});
});
Loading