Skip to content
Merged
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