Skip to content
Draft
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ holocron serve --port 3666
{ "directory": "/path/to/repo" }
```

The directory must exist and sit inside an allowed root. By default that is the directory `holocron serve` was started in; pass `--allow-root` to name others (`holocron serve --allow-root ~/code/app ~/code/lib`). Anything else, including `..` traversal and symlinks that lead outside a root, gets `403`. The API has no authentication, so this keeps a request from indexing, and then searching, arbitrary files on the machine.

---

## Configuration
Expand Down
56 changes: 56 additions & 0 deletions src/api/rootGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { realpath } from 'node:fs/promises';
import { isAbsolute, relative, resolve, sep } from 'node:path';
import { isWithinRoot } from '../context/fileIndexer.js';

/**
* Canonicalise the roots the REST API may index (make each absolute and
* resolve its symlinks). Roots that do not exist are dropped.
*/
export async function canonicalRoots(roots: readonly string[]): Promise<string[]> {
const canonical: string[] = [];
for (const root of roots) {
try {
canonical.push(await realpath(resolve(root)));
} catch {
// A missing root allows nothing.
}
}
return canonical;
}

/**
* Map a directory named in a REST request to the canonical directory to
* index, or null when it is not inside one of `allowedRoots` (canonical, from
* canonicalRoots()).
*
* POST /index takes its directory from the network, so it must not be able to
* name an arbitrary path on disk. Two checks, in this order:
* 1. Lexical, before touching the filesystem: the resolved path must sit at
* or under an allowed root. `..` segments and absolute paths elsewhere fail
* here. The check is written inline (path.relative, then a `..` prefix
* test) rather than through isWithinRoot() so that static analysis
* (CodeQL js/path-injection) recognises it as the sanitiser.
* 2. After realpath(): the real location must still be inside an allowed
* root, so a symlink under a root that points outside it is rejected too.
*
* Returns null as well when the directory does not exist.
*/
export async function resolveAllowedDirectory(
directory: string,
allowedRoots: readonly string[],
): Promise<string | null> {
const requested = resolve(directory);
for (const root of allowedRoots) {
const rel = relative(root, requested);
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) continue;

let real: string;
try {
real = await realpath(requested);
} catch {
return null;
}
return allowedRoots.some((allowed) => isWithinRoot(allowed, real)) ? real : null;
}
return null;
}
20 changes: 18 additions & 2 deletions src/api/routes/indexDir.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import type { FastifyInstance } from 'fastify';
import type { ContextEngine } from '../../context/contextEngine.js';
import type { IndexDirBody, IndexDirResponse } from '../schemas.js';
import { canonicalRoots, resolveAllowedDirectory } from '../rootGuard.js';

export function registerIndexDirRoute(app: FastifyInstance, contextEngine: ContextEngine): void {
/**
* POST /index. The directory comes from the request body, so it is only
* indexed when it lies inside one of `allowedRoots` (`holocron serve
* --allow-root`, default: the directory the server was started in).
*/
export function registerIndexDirRoute(
app: FastifyInstance,
contextEngine: ContextEngine,
allowedRoots: readonly string[],
): void {
app.post<{ Body: IndexDirBody; Reply: IndexDirResponse }>('/index', async (req, reply) => {
const { directory } = req.body;
if (!directory || typeof directory !== 'string') {
return reply
.status(400)
.send({ error: 'directory is required' } as unknown as IndexDirResponse);
}
const result = await contextEngine.indexDirectory(directory);
const target = await resolveAllowedDirectory(directory, await canonicalRoots(allowedRoots));
if (target === null) {
return reply.status(403).send({
error: 'directory must be an existing directory inside an allowed root (holocron serve --allow-root)',
} as unknown as IndexDirResponse);
}
const result = await contextEngine.indexDirectory(target);
return reply.send({
indexedFiles: result.indexedFiles,
chunks: result.chunks,
Expand Down
7 changes: 6 additions & 1 deletion src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { registerIndexDirRoute } from './routes/indexDir.js';
export interface ApiServerDeps {
contextEngine: ContextEngine;
inferenceBackend?: InferenceBackend;
/**
* Directories POST /index may index (each one and everything under it).
* Defaults to the current working directory.
*/
allowedRoots?: readonly string[];
}

/**
Expand All @@ -21,7 +26,7 @@ export function createApiServer(deps: ApiServerDeps): FastifyInstance {
registerSearchRoute(app, deps.contextEngine);
registerEnhanceRoute(app, deps.contextEngine);
registerAskRoute(app, deps.contextEngine, deps.inferenceBackend);
registerIndexDirRoute(app, deps.contextEngine);
registerIndexDirRoute(app, deps.contextEngine, deps.allowedRoots ?? [process.cwd()]);

return app;
}
3 changes: 2 additions & 1 deletion src/backends/anthropicBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { InferenceRequest, InferenceResponse, InferenceChunk } from '../types/inference.types.js';
import type { InferenceBackend } from './inferenceBackend.js';
import { BackendError } from '../errors/backend.js';
import { trimTrailingSlashes } from './baseUrl.js';

export class AnthropicBackend implements InferenceBackend {
private readonly baseUrl: string;
Expand All @@ -12,7 +13,7 @@

constructor(config: AnthropicConfig) {
const envBaseUrl = process.env['ANTHROPIC_BASE_URL'];
this.baseUrl = (envBaseUrl ?? config.baseUrl ?? 'https://api.anthropic.com').replace(/\/+$/, '');
this.baseUrl = trimTrailingSlashes(envBaseUrl ?? config.baseUrl ?? 'https://api.anthropic.com');
this.apiKey = config.apiKey;
this.model = config.model;
this.temperature = config.temperature;
Expand Down Expand Up @@ -130,7 +131,7 @@
let buffer = '';

try {
while (true) {

Check warning on line 134 in src/backends/anthropicBackend.ts

View workflow job for this annotation

GitHub Actions / Lint

Unnecessary conditional, value is always truthy
const { done, value } = await reader.read();
if (done) break;

Expand Down
16 changes: 16 additions & 0 deletions src/backends/baseUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const SLASH = 0x2f; // '/'

/**
* Remove every trailing '/' from a base URL.
*
* Deliberately not `url.replace(/\/+$/, '')`: that regex backtracks on input
* with a long run of slashes that is not at the end (e.g. '/'.repeat(n) + 'x'),
* because the engine retries the `\/+` match from every start position,
* which is O(n²). The base URL can come from config files and environment
* variables, so this scans backwards once instead: O(n), no regex.
*/
export function trimTrailingSlashes(url: string): string {
let end = url.length;
while (end > 0 && url.charCodeAt(end - 1) === SLASH) end--;
return end === url.length ? url : url.slice(0, end);
}
3 changes: 2 additions & 1 deletion src/backends/ollamaBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { InferenceRequest, InferenceResponse, InferenceChunk } from '../types/inference.types.js';
import type { InferenceBackend } from './inferenceBackend.js';
import { BackendError } from '../errors/backend.js';
import { trimTrailingSlashes } from './baseUrl.js';

export class OllamaBackend implements InferenceBackend {
private readonly baseUrl: string;
Expand All @@ -10,7 +11,7 @@
private readonly maxTokens: number | undefined;

constructor(config: OllamaConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
this.baseUrl = trimTrailingSlashes(config.baseUrl);
this.model = config.model;
this.temperature = config.temperature;
this.maxTokens = config.maxTokens;
Expand Down Expand Up @@ -86,7 +87,7 @@
let buffer = '';

try {
while (true) {

Check warning on line 90 in src/backends/ollamaBackend.ts

View workflow job for this annotation

GitHub Actions / Lint

Unnecessary conditional, value is always truthy
const { done, value } = await reader.read();
if (done) break;

Expand Down
3 changes: 2 additions & 1 deletion src/backends/openaiCompatibleBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { InferenceRequest, InferenceResponse, InferenceChunk } from '../types/inference.types.js';
import type { InferenceBackend } from './inferenceBackend.js';
import { BackendError } from '../errors/backend.js';
import { trimTrailingSlashes } from './baseUrl.js';

export class OpenAICompatibleBackend implements InferenceBackend {
private readonly baseUrl: string;
Expand All @@ -11,7 +12,7 @@
private readonly maxTokens: number | undefined;

constructor(config: OpenAICompatibleConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
this.baseUrl = trimTrailingSlashes(config.baseUrl);
this.apiKey = config.apiKey;
this.model = config.model;
this.temperature = config.temperature;
Expand Down Expand Up @@ -97,7 +98,7 @@
let buffer = '';

try {
while (true) {

Check warning on line 101 in src/backends/openaiCompatibleBackend.ts

View workflow job for this annotation

GitHub Actions / Lint

Unnecessary conditional, value is always truthy
const { done, value } = await reader.read();
if (done) break;

Expand Down
12 changes: 10 additions & 2 deletions src/cli/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ export function registerServeCommand(program: Command): void {
.description('Start the REST API server')
.option('--port <n>', 'Port to listen on', '3666')
.option('--host <h>', 'Host to bind to', '127.0.0.1')
.action(async (opts: { port: string; host: string }) => {
.option(
'--allow-root <dirs...>',
'Directories POST /index may index (default: the current directory)',
)
.action(async (opts: { port: string; host: string; allowRoot?: string[] }) => {
const config = loadConfig();
const engine = await createContextEngine(config.context);
const backend = createBackend(config.backend);
const app = createApiServer({ contextEngine: engine, inferenceBackend: backend });
const app = createApiServer({
contextEngine: engine,
inferenceBackend: backend,
allowedRoots: opts.allowRoot ?? [process.cwd()],
});

const port = parseInt(opts.port, 10);
const host = opts.host;
Expand Down
2 changes: 2 additions & 0 deletions src/context/contextEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface ContextEngine {

/**
* Index (or re-index) a specific list of files.
* Implementations must only read files inside a directory previously
* passed to indexDirectory(); other paths are skipped.
*/
indexFiles(filePaths: string[]): Promise<void>;

Expand Down
Loading
Loading