Skip to content
Open
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src/expansion-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ export class ExpansionAuthManager {
}
}

// 6. Depth must not exceed grant maxDepth
if (request.depth > grant.maxDepth) {
return {
valid: false,
reason: `Requested depth ${request.depth} exceeds grant maximum ${grant.maxDepth}`,
};
}

return { valid: true };
}

Expand Down
18 changes: 16 additions & 2 deletions src/store/conversation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";
import { sanitizeFts5Query } from "./fts5-sanitize.js";
import { buildLikeSearchPlan, createFallbackSnippet } from "./full-text-fallback.js";
import { UnsafeRegexError, validateRegexSafety } from "./regex-safety.js";

export type ConversationId = number;
export type MessageId = number;
Expand Down Expand Up @@ -203,6 +204,8 @@ function toMessagePartRecord(row: MessagePartRow): MessagePartRecord {

// ── ConversationStore ─────────────────────────────────────────────────────────

const MAX_REGEX_SCAN_ROWS = 10_000;

export class ConversationStore {
private readonly fts5Available: boolean;

Expand Down Expand Up @@ -700,7 +703,17 @@ export class ConversationStore {
before?: Date,
): MessageSearchResult[] {
// SQLite has no native POSIX regex; fetch candidates and filter in JS
const re = new RegExp(pattern);
const validation = validateRegexSafety(pattern);
if (!validation.safe) {
throw new UnsafeRegexError(`Invalid/unsafe regex: ${validation.reason ?? "Pattern not allowed"}`);
}

let re: RegExp;
try {
re = new RegExp(pattern);
} catch {
throw new UnsafeRegexError("Invalid/unsafe regex: Invalid regular expression");
}

const where: string[] = [];
const args: Array<string | number> = [];
Expand All @@ -722,7 +735,8 @@ export class ConversationStore {
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
FROM messages
${whereClause}
ORDER BY created_at DESC`,
ORDER BY created_at DESC
LIMIT ${MAX_REGEX_SCAN_ROWS}`,
)
.all(...args) as unknown as MessageRow[];

Expand Down
43 changes: 43 additions & 0 deletions src/store/regex-safety.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const NESTED_QUANTIFIER_RE = /\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)[+*{]/;
const MAX_PATTERN_LENGTH = 500;

export type RegexValidationResult = {
safe: boolean;
reason?: string;
};

export class UnsafeRegexError extends Error {
readonly code = "UNSAFE_REGEX";

constructor(message: string) {
super(message);
this.name = "UnsafeRegexError";
}
}

export function validateRegexSafety(pattern: string): RegexValidationResult {
if (pattern.length > MAX_PATTERN_LENGTH) {
return {
safe: false,
reason: `Pattern exceeds maximum length of ${MAX_PATTERN_LENGTH}`,
};
}

if (NESTED_QUANTIFIER_RE.test(pattern)) {
return {
safe: false,
reason: "Pattern contains nested quantifiers (potential ReDoS)",
};
}

try {
new RegExp(pattern);
} catch {
return {
safe: false,
reason: "Invalid regular expression",
};
}

return { safe: true };
}
18 changes: 16 additions & 2 deletions src/store/summary-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DatabaseSync } from "node:sqlite";
import { sanitizeFts5Query } from "./fts5-sanitize.js";
import { buildLikeSearchPlan, createFallbackSnippet } from "./full-text-fallback.js";
import { UnsafeRegexError, validateRegexSafety } from "./regex-safety.js";

export type SummaryKind = "leaf" | "condensed";
export type ContextItemType = "message" | "summary";
Expand Down Expand Up @@ -239,6 +240,8 @@ function toLargeFileRecord(row: LargeFileRow): LargeFileRecord {

// ── SummaryStore ──────────────────────────────────────────────────────────────

const MAX_REGEX_SCAN_ROWS = 10_000;

export class SummaryStore {
private readonly fts5Available: boolean;

Expand Down Expand Up @@ -818,7 +821,17 @@ export class SummaryStore {
since?: Date,
before?: Date,
): SummarySearchResult[] {
const re = new RegExp(pattern);
const validation = validateRegexSafety(pattern);
if (!validation.safe) {
throw new UnsafeRegexError(`Invalid/unsafe regex: ${validation.reason ?? "Pattern not allowed"}`);
}

let re: RegExp;
try {
re = new RegExp(pattern);
} catch {
throw new UnsafeRegexError("Invalid/unsafe regex: Invalid regular expression");
}

const where: string[] = [];
const args: Array<string | number> = [];
Expand All @@ -842,7 +855,8 @@ export class SummaryStore {
source_message_token_count, created_at
FROM summaries
${whereClause}
ORDER BY created_at DESC`,
ORDER BY created_at DESC
LIMIT ${MAX_REGEX_SCAN_ROWS}`,
)
.all(...args) as unknown as SummaryRow[];

Expand Down
71 changes: 60 additions & 11 deletions src/tools/lcm-conversation-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { LcmDependencies } from "../types.js";

export type LcmConversationScope = {
conversationId?: number;
conversationIds?: number[];
allConversations: boolean;
};

Expand Down Expand Up @@ -44,33 +45,81 @@ export async function resolveLcmConversationScope(input: {
sessionId?: string;
sessionKey?: string;
deps?: Pick<LcmDependencies, "resolveSessionIdFromSessionKey">;
grantContext?: {
isSubagent: boolean;
allowedConversationIds?: number[];
};
}): Promise<LcmConversationScope> {
const { lcm, params } = input;
const allowedConversationIds = input.grantContext?.allowedConversationIds;
const isSubagent = input.grantContext?.isSubagent === true;
const hasDelegatedGrant =
isSubagent && Array.isArray(allowedConversationIds) && allowedConversationIds.length > 0;

let cachedSessionConversationId: number | undefined;
let resolvedSessionConversationId = false;

const resolveSessionConversationId = async (): Promise<number | undefined> => {
if (resolvedSessionConversationId) {
return cachedSessionConversationId;
}

let normalizedSessionId = input.sessionId?.trim();
if (!normalizedSessionId && input.sessionKey && input.deps) {
normalizedSessionId = await input.deps.resolveSessionIdFromSessionKey(input.sessionKey.trim());
}
if (!normalizedSessionId) {
resolvedSessionConversationId = true;
return undefined;
}

const conversation = await lcm.getConversationStore().getConversationBySessionId(normalizedSessionId);
cachedSessionConversationId = conversation?.conversationId;
resolvedSessionConversationId = true;
return cachedSessionConversationId;
};

const enforceConversationAccess = async (conversationId: number): Promise<void> => {
if (hasDelegatedGrant) {
if (!allowedConversationIds.includes(conversationId)) {
throw new Error(`Conversation ${conversationId} is not in delegated grant scope.`);
}
return;
}

if (isSubagent) {
const sessionConversationId = await resolveSessionConversationId();
if (sessionConversationId == null || sessionConversationId !== conversationId) {
throw new Error(`Conversation ${conversationId} is not available in this session.`);
}
}
};

const explicitConversationId =
typeof params.conversationId === "number" && Number.isFinite(params.conversationId)
? Math.trunc(params.conversationId)
: undefined;
if (explicitConversationId != null) {
await enforceConversationAccess(explicitConversationId);
return { conversationId: explicitConversationId, allConversations: false };
}

if (params.allConversations === true) {
if (hasDelegatedGrant) {
return { conversationIds: [...allowedConversationIds], allConversations: false };
}
if (isSubagent) {
const sessionConversationId = await resolveSessionConversationId();
return { conversationId: sessionConversationId, allConversations: false };
}
return { conversationId: undefined, allConversations: true };
}

let normalizedSessionId = input.sessionId?.trim();
if (!normalizedSessionId && input.sessionKey && input.deps) {
normalizedSessionId = await input.deps.resolveSessionIdFromSessionKey(input.sessionKey.trim());
}
if (!normalizedSessionId) {
return { conversationId: undefined, allConversations: false };
}

const conversation = await lcm.getConversationStore().getConversationBySessionId(normalizedSessionId);
if (!conversation) {
const conversationId = await resolveSessionConversationId();
if (conversationId == null) {
return { conversationId: undefined, allConversations: false };
}

return { conversationId: conversation.conversationId, allConversations: false };
await enforceConversationAccess(conversationId);
return { conversationId, allConversations: false };
}
56 changes: 44 additions & 12 deletions src/tools/lcm-describe-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,36 @@ export function createLcmDescribeTool(input: {
const timezone = input.lcm.timezone;
const p = params as Record<string, unknown>;
const id = (p.id as string).trim();
const conversationScope = await resolveLcmConversationScope({
lcm: input.lcm,
deps: input.deps,
sessionId: input.sessionId,
sessionKey: input.sessionKey,
params: p,
});
if (!conversationScope.allConversations && conversationScope.conversationId == null) {
const sessionKey = (input.sessionKey ?? input.sessionId ?? "").trim();
const isSubagent = input.deps.isSubagentSessionKey(sessionKey);
const grantId = isSubagent ? resolveDelegatedExpansionGrantId(sessionKey) : null;
const grant = grantId ? getRuntimeExpansionAuthManager().getGrant(grantId) : null;
const grantContext = {
isSubagent,
allowedConversationIds: grant?.allowedConversationIds,
};

let conversationScope: Awaited<ReturnType<typeof resolveLcmConversationScope>>;
try {
conversationScope = await resolveLcmConversationScope({
lcm: input.lcm,
deps: input.deps,
sessionId: input.sessionId,
sessionKey: input.sessionKey,
params: p,
grantContext,
});
} catch (scopeError) {
return jsonResult({
error: `Not found: ${id}`,
hint: "Check the ID format (sum_xxx for summaries, file_xxx for files).",
});
}
if (
!conversationScope.allConversations &&
conversationScope.conversationId == null &&
(!conversationScope.conversationIds || conversationScope.conversationIds.length === 0)
) {
return jsonResult({
error:
"No LCM conversation found for this session. Provide conversationId or set allConversations=true.",
Expand All @@ -91,13 +113,23 @@ export function createLcmDescribeTool(input: {
hint: "Check the ID format (sum_xxx for summaries, file_xxx for files).",
});
}
const itemConversationId =
result.type === "summary" ? result.summary?.conversationId : result.file?.conversationId;
if (conversationScope.conversationId != null) {
const itemConversationId =
result.type === "summary" ? result.summary?.conversationId : result.file?.conversationId;
if (itemConversationId != null && itemConversationId !== conversationScope.conversationId) {
return jsonResult({
error: `Not found in conversation ${conversationScope.conversationId}: ${id}`,
hint: "Use allConversations=true for cross-conversation lookup.",
error: `Not found: ${id}`,
hint: "Check the ID format (sum_xxx for summaries, file_xxx for files).",
});
}
} else if (conversationScope.conversationIds && conversationScope.conversationIds.length > 0) {
if (
itemConversationId != null &&
!conversationScope.conversationIds.includes(itemConversationId)
) {
return jsonResult({
error: `Not found: ${id}`,
hint: "Check the ID format (sum_xxx for summaries, file_xxx for files).",
});
}
}
Expand Down
Loading