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
10 changes: 6 additions & 4 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,16 +290,16 @@ There are three tool “levels”. They all serve the agent; higher levels are u
- External plugins are discovered from `DATA_DIR/plugins/*`.
- Key ones:
- `bash` (`apps/core/src/tools/bash.ts`), guarded by `apps/core/src/tools/bash-safety/*` unless `dangerouslyAllow=true`.
- Bash safety is an evidence-only accidental-damage guardrail. It blocks statically identified destructive operations and sensitive-path access, but parsing failures, unsupported syntax, and runtime-dependent behavior fail open. Dynamic `rm -rf` targets are the deliberate exception and remain blocked because their deletion scope cannot be verified.
- Bash safety is an evidence-only accidental-damage guardrail. It blocks statically identified destructive operations and sensitive-path access, including direct static access to `DATA_DIR/secret`, but parsing failures, unsupported syntax, and runtime-dependent behavior fail open. Dynamic `rm -rf` targets are the deliberate exception and remain blocked because their deletion scope cannot be verified.
- Child env always includes request context vars (`LILAC_REQUEST_ID`, `LILAC_SESSION_ID`, `LILAC_REQUEST_CLIENT`, `LILAC_CWD`) and VCS vars (`GIT_CONFIG_GLOBAL`, `GNUPGHOME`, with color forced off via `NO_COLOR=1`).
- Trusted local bash also loads `$DATA_DIR/secret/tool-env.jsonc` before each process. This overlay is not used for restricted bash or SSH execution.
- Bash output redaction is best-effort accidental-leak prevention, not a security boundary. Trusted local commands can read, transform, or transmit their environment and same-user files; use restricted bash or OS-level isolation when commands must not access secrets.
- Bash path denial and output redaction are best-effort accidental-leak prevention, not a security boundary. Trusted local commands can evade static analysis and read, transform, or transmit their environment and same-user files; use restricted bash or OS-level isolation when commands must not access secrets.
- When GitHub outbound auth is configured, bash also injects GitHub auth vars from `apps/core/src/github/github-auth.ts`:
- Canonical: `GH_TOKEN`, `GITHUB_TOKEN` (prefer user token when configured, otherwise app token).
- Optional host: `GH_HOST`.
- Explicit alternates: `LILAC_GITHUB_USER_TOKEN`, `LILAC_GITHUB_USER_HOST`, `LILAC_GITHUB_APP_TOKEN`, `LILAC_GITHUB_APP_HOST`.
- This allows command-level override to app auth when needed (for example: `GH_TOKEN="$LILAC_GITHUB_APP_TOKEN" gh ...`).
- `read_file`, `glob`, `grep` (`apps/core/src/tools/fs/fs.ts`) (denylists include `DATA_DIR/secret`, `~/.ssh`, `~/.aws`, `~/.gnupg` unless `dangerouslyAllow=true`).
- `read_file`, `glob`, `grep` (`apps/core/src/tools/fs/fs.ts`) (normal-operation denylists include `DATA_DIR/secret`, including MCP OAuth credentials, plus `~/.ssh`, `~/.aws`, and `~/.gnupg` unless `dangerouslyAllow=true`).
- `apply_patch` (`apps/core/src/tools/apply-patch/index.ts`) (format docs: `apps/core/src/tools/apply-patch/README.md`; remote denylist can be bypassed with `dangerouslyAllow=true`).
- `batch` (`apps/core/src/tools/batch.ts`) expands one call into ordinary synthetic Level 1 tool-call/result pairs.
- `subagent_delegate` (`apps/core/src/tools/subagent.ts`) when `agent.subagents` is enabled and depth limits allow delegation. Its model argument is generated from agent-selectable `models.def` aliases, with optional per-call reasoning overrides and config-authored routing guidance.
Expand All @@ -319,6 +319,7 @@ There are three tool “levels”. They all serve the agent; higher levels are u
- The tool server uses request context headers (`x-lilac-request-id`, etc.) and generic server-issued request capabilities for request-scoped behavior. Capabilities bind cwd and native profile identity; profile headers are context only and cannot expand Level-2 access.
- `apps/tool-bridge/client.ts` provides a human-friendly `tools` CLI that calls the tool server; the agent can also invoke it through Level-1 `bash`.
- Capability-bound plugins skip cleanly in dev mode when required services are absent.
- Configured MCP servers are Core-owned, process-wide clients managed through ordinary `mcp.list`, `mcp.add`, `mcp.remove`, `mcp.status`, `mcp.auth`, and `mcp.reload` callable IDs. Every accepted server is attempted at startup; unavailable servers wait for explicit reload rather than retrying on requests or in the background. Load the `mcp-management` skill for operational syntax and OAuth flow.
- Health distinguishes fatal liveness failures from readiness degradation. Sustained event-loop
lag is readiness-only: it is retained as a diagnostic incident and can make `/readyz` return
503, but lag alone never invokes the process watchdog. Incident diagnostics contain process
Expand Down Expand Up @@ -353,6 +354,7 @@ There are three tool “levels”. They all serve the agent; higher levels are u
Expected contents over time:

- `core-config.yaml` (seeded from `packages/utils/config-templates/core-config.example.yaml` if missing)
- `mcp-config.yaml` (independently versioned configured MCP servers)
- `prompts/` (seeded from `packages/utils/prompt-templates/*` if missing)
- `discord-surface.db` (Discord cache DB; default path)
- `discord-search.db` (Discord search index DB)
Expand All @@ -361,7 +363,7 @@ Expected contents over time:
- `graceful-restart.db` (in-flight relay/agent recovery snapshots)
- `skills/` (skill bundles installed/seeded for discovery)
- `plugins/` (external Level 1 / Level 2 tool plugins)
- `secret/` (persisted secrets, e.g. GitHub App credentials, GPG home)
- `secret/` (persisted secrets, e.g. GitHub App credentials, GPG home, and `mcp-oauth/<server-id>.json`)
- `workspace/` (default working directory for bash/fs tools in the core runtime)

Onboarding-related tools may also create additional persisted directories under `DATA_DIR` (for example `bin/`, `.bun/`, `.npm-global/`, `.config/`, `tmp/`).
Expand Down
1 change: 1 addition & 0 deletions apps/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"dependencies": {
"@ai-sdk/google": "^4.0.12",
"@ai-sdk/mcp": "^2.0.16",
"@ff-labs/fff-node": "^0.9.4",
"@mozilla/readability": "^0.6.0",
"@octokit/auth-app": "^8.2.0",
Expand Down
155 changes: 155 additions & 0 deletions apps/core/src/mcp/catalog-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { createHash } from "node:crypto";

import { z } from "zod";

export const CATALOG_TOOL_ID_VERSION = 1 as const;
export const MAX_MODEL_TOOL_NAME_LENGTH = 64;

export const catalogToolIdentitySchema = z.strictObject({
source: z.enum(["plugin", "mcp"]),
sourceId: z.string().min(1),
rawToolName: z.string().min(1),
});

export type CatalogToolIdentity = z.infer<typeof catalogToolIdentitySchema>;

const stableIdSchema = z.tuple([
z.literal("lilac.catalog-tool"),
z.literal(CATALOG_TOOL_ID_VERSION),
z.enum(["plugin", "mcp"]),
z.string().min(1),
z.string().min(1),
]);

export type CatalogStableIdParseResult =
| { ok: true; identity: CatalogToolIdentity }
| { ok: false; error: string };

/** A versioned, delimiter-safe persistence key. */
export function catalogToolStableId(identity: CatalogToolIdentity): string {
const parsed = catalogToolIdentitySchema.parse(identity);
return JSON.stringify([
"lilac.catalog-tool",
CATALOG_TOOL_ID_VERSION,
parsed.source,
parsed.sourceId,
parsed.rawToolName,
]);
}

export function parseCatalogToolStableId(stableId: string): CatalogStableIdParseResult {
let decoded: unknown;
try {
decoded = JSON.parse(stableId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { ok: false, error: `invalid catalog tool ID JSON: ${message}` };
}

const parsed = stableIdSchema.safeParse(decoded);
if (!parsed.success) return { ok: false, error: z.prettifyError(parsed.error) };
return {
ok: true,
identity: { source: parsed.data[2], sourceId: parsed.data[3], rawToolName: parsed.data[4] },
};
}

function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

function normalizeNameSegment(value: string): string {
const normalized = value.replace(/[^0-9A-Za-z_]+/g, "_").replace(/_+/g, "_");
return normalized.replace(/^_+|_+$/g, "");
}

function identityHash(identity: CatalogToolIdentity): string {
return createHash("sha256").update(catalogToolStableId(identity)).digest("hex").slice(0, 10);
}

function appendHash(base: string, identity: CatalogToolIdentity): string {
const suffix = `_${identityHash(identity)}`;
const available = MAX_MODEL_TOOL_NAME_LENGTH - suffix.length;
const stem = base.slice(0, available).replace(/_+$/g, "") || "tool";
return `${stem}${suffix}`;
}

/** Deterministic candidate before catalog-wide collision resolution. */
export function baseCatalogToolName(identity: CatalogToolIdentity): string {
const parsed = catalogToolIdentitySchema.parse(identity);
const sourceId = normalizeNameSegment(parsed.sourceId) || "source";
const rawToolName = normalizeNameSegment(parsed.rawToolName) || "tool";
const base = `${parsed.source}_${sourceId}_${rawToolName}`;
return base.length <= MAX_MODEL_TOOL_NAME_LENGTH ? base : appendHash(base, parsed);
}

export type CatalogToolNameCollision = {
readonly modelName: string;
readonly identities: readonly CatalogToolIdentity[];
readonly reserved: boolean;
};

export type CatalogToolNameAssignment = {
/** Stable catalog ID to provider-facing tool name. */
readonly byStableId: ReadonlyMap<string, string>;
/** Provider-facing tool name to structured source identity. */
readonly byModelName: ReadonlyMap<string, CatalogToolIdentity>;
/** Normally empty; detects a reserved or hash-suffix collision without shadowing. */
readonly collisions: readonly CatalogToolNameCollision[];
};

export function assignCatalogToolNames(
identities: readonly CatalogToolIdentity[],
reservedNames: ReadonlySet<string> = new Set(),
): CatalogToolNameAssignment {
const unique = new Map<string, CatalogToolIdentity>();
for (const identity of identities) {
const parsed = catalogToolIdentitySchema.parse(identity);
unique.set(catalogToolStableId(parsed), parsed);
}
const sorted = [...unique.entries()].sort(([left], [right]) => compareText(left, right));

const baseCounts = new Map<string, number>();
for (const [, identity] of sorted) {
const base = baseCatalogToolName(identity);
baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
}

const finalGroups = new Map<string, Array<{ stableId: string; identity: CatalogToolIdentity }>>();
for (const [stableId, identity] of sorted) {
const base = baseCatalogToolName(identity);
const modelName =
(baseCounts.get(base) ?? 0) > 1 || reservedNames.has(base)
? appendHash(base, identity)
: base;
const group = finalGroups.get(modelName) ?? [];
group.push({ stableId, identity });
finalGroups.set(modelName, group);
}

const byStableId = new Map<string, string>();
const byModelName = new Map<string, CatalogToolIdentity>();
const collisions: CatalogToolNameCollision[] = [];

for (const modelName of [...finalGroups.keys()].sort(compareText)) {
const group = finalGroups.get(modelName);
if (!group) continue;
const reserved = reservedNames.has(modelName);
if (reserved || group.length > 1) {
collisions.push({
modelName,
identities: group.map(({ identity }) => identity),
reserved,
});
continue;
}

const onlyEntry = group[0];
if (!onlyEntry) continue;
const { stableId, identity } = onlyEntry;
byStableId.set(stableId, modelName);
byModelName.set(modelName, identity);
}

return { byStableId, byModelName, collisions };
}
Loading