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
16 changes: 0 additions & 16 deletions .changeset/lessons-effectiveness.md

This file was deleted.

7 changes: 0 additions & 7 deletions .changeset/lessons-scope-always.md

This file was deleted.

7 changes: 0 additions & 7 deletions .changeset/lint-best-effort-hooks.md

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/mcp-symlink-containment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agentsmesh": patch
---

Reject MCP read, write, and delete paths that escape the project directory through symlinks. Containment is anchored at the project root (not `.agentsmesh`), so a symlinked config file **or** a symlinked `.agentsmesh` parent directory can no longer leak or overwrite files outside the project. Covers skill and canonical (rules/commands/agents) list/get/create/update/delete, plus all config reads and writes (`get_config`/`update_config`, permissions, hooks, ignore, and MCP-server tools). Note: config reads that escape through a symlink now raise `PATH_TRAVERSAL` instead of returning `null`.
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Changelog

## 0.30.0

### Minor Changes

- 7b795b8: Lessons: measure effectiveness and sharpen recall

The lessons subsystem now tracks whether a recalled rule actually prevented the repeat, and improves recall precision — all opt-in and backward-compatible (no change to the CLI, MCP, or `lessons.json` surface):
- **Effectiveness signal (opt-in via `AGENTSMESH_LESSONS_TELEMETRY=1`).** An append-only outcome log records deliveries and failures; effectiveness is derived at read time — did a delivered lesson prevent the repeat? Recall **down-ranks** a lesson that fired but never helped, `lessons validate` gains advisory `INEFFECTIVE_LESSON` and `UNCOVERED_FAILURE` findings, and `lessons stats` gains a coarse **effectiveness block** (deliveries, held rate, ineffective count). The signal is deliberately coarse and labeled as such — never presented as proof of prevention.
- **Diff-aware recall.** The recall hook binds on the content being written, not just the file path, so a keyword lesson can fire on the change itself.
- **camelCase keyword reach.** Keyword recall now also splits camelCase/acronym identifier sub-words while retaining the whole token (fully backward compatible), so a keyword like `guard` reaches `useLeaveGuard.ts`.
- **Quieter injection.** Automatic recall caps the injected set to the most relevant few and stays silent on no match.
- **Recurrence-driven capture nudge.** A failure recurring on the same action with no covering lesson escalates the capture reminder, pre-filled from the real file/command.
- **Portable failure detection.** The recall hook records a failure from any tool event carrying an error payload, not only Claude Code's dedicated `PostToolUseFailure` event — so effectiveness can accumulate on more harnesses.
- **New capture warning `WIDE_GLOB_MATCH`** flags a `file_glob` that matches a large share of the working tree, complementing the existing structural broad-glob check.
- **`agentsmesh init --lessons`** now commits the `.gitattributes` binding for the `lessons.json` union merge driver and prints the per-clone `git config` setup, so a team's concurrent captures merge cleanly instead of conflicting.

- 7b795b8: Lessons: `--scope always` for universal always-on rules

A lesson can now be captured with `--scope always` (MCP: `scope: "always"`) for a universal standard that applies to every task — a comment/test/style convention that no single file or command names. Always-on lessons need no trigger and are delivered on every task automatically, alongside triggered recall, deduplicated once per session.

### Patch Changes

- 13730ca: Lint no longer warns about agentsmesh-injected best-effort hook events

`agentsmesh lint` previously flagged the recall/capture hook events agentsmesh itself injects (`UserPromptSubmit`, `PostToolUseFailure`, `SessionStart`) as "unsupported" on targets whose hook model can't represent them — an unactionable warning, since agentsmesh injects those events and degrades them gracefully. They are now excluded from the unsupported-hook warning across every whitelist-style target.

## 0.29.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentsmesh",
"version": "0.29.0",
"version": "0.30.0",
"description": "One canonical source for AI coding agent rules, commands, skills, MCP, hooks, and permissions — synced across every major AI coding tool.",
"type": "module",
"main": "./dist/index.js",
Expand Down
35 changes: 34 additions & 1 deletion src/mcp/handlers/canonical-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { McpContext } from '../context.js';
import { McpError } from '../errors.js';
import { MAX_DIR_ENTRIES } from '../limits.js';
import { safeWrite } from '../writers/safe-write.js';
import { assertContainedPath } from '../writers/path-containment.js';
import { parseMd, serializeMd } from '../writers/md-frontmatter.js';

// Flat identifier only — `/` is intentionally excluded so canonical names
Expand Down Expand Up @@ -82,6 +83,12 @@ export function createCanonicalHandlers<TSummary>(

return {
async list(ctx) {
// Reject a symlinked feature dir before reading any file through it.
await assertContainedPath({
root: ctx.projectRoot,
target: featureDir(ctx.projectRoot),
message: `path escapes ${feature} directory`,
});
const files = await listFiles(ctx.projectRoot);
const out: TSummary[] = [];
for (const f of files) {
Expand All @@ -96,6 +103,12 @@ export function createCanonicalHandlers<TSummary>(
async get(ctx, { name }) {
checkName(name);
const file = pathFor(ctx.projectRoot, feature, name);
await assertContainedPath({
root: featureDir(ctx.projectRoot),
target: file,
boundaryRoot: ctx.projectRoot,
message: `path escapes ${feature} directory`,
});
try {
const src = await readFile(file, 'utf8');
const { frontmatter, body } = parseMd(src);
Expand All @@ -111,11 +124,19 @@ export function createCanonicalHandlers<TSummary>(

async create(ctx, { name, frontmatter, body, dry_run }) {
checkName(name);
const file = pathFor(ctx.projectRoot, feature, name);
// Assert containment BEFORE the existence probe so a symlinked feature dir
// cannot leak an out-of-project filename-existence oracle via ALREADY_EXISTS.
await assertContainedPath({
root: featureDir(ctx.projectRoot),
target: file,
boundaryRoot: ctx.projectRoot,
message: `path escapes ${feature} directory`,
});
const parsed = frontmatterSchema.safeParse(frontmatter);
if (!parsed.success) {
throw new McpError('VALIDATION_FAILED', 'invalid frontmatter', parsed.error.issues);
}
const file = pathFor(ctx.projectRoot, feature, name);
if (await exists(file)) throw new McpError('ALREADY_EXISTS', `${feature} "${name}" exists`);
const all = await listFiles(ctx.projectRoot);
if (all.length >= MAX_DIR_ENTRIES) {
Expand All @@ -136,6 +157,12 @@ export function createCanonicalHandlers<TSummary>(
checkName(name);
const file = pathFor(ctx.projectRoot, feature, name);
let current: { frontmatter: Record<string, unknown>; body: string };
await assertContainedPath({
root: featureDir(ctx.projectRoot),
target: file,
boundaryRoot: ctx.projectRoot,
message: `path escapes ${feature} directory`,
});
try {
const src = await readFile(file, 'utf8');
current = parseMd(src);
Expand Down Expand Up @@ -174,6 +201,12 @@ export function createCanonicalHandlers<TSummary>(
throw new McpError('PROTECTED_FILE', `${feature} "${name}" requires force: true`);
}
const file = pathFor(ctx.projectRoot, feature, name);
await assertContainedPath({
root: featureDir(ctx.projectRoot),
target: file,
boundaryRoot: ctx.projectRoot,
message: `path escapes ${feature} directory`,
});
if (!(await exists(file))) throw new McpError('NOT_FOUND', `${feature} "${name}" not found`);
if (dry_run === true) return { path: file, deleted: false };
await rm(file);
Expand Down
61 changes: 48 additions & 13 deletions src/mcp/handlers/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,27 @@ import type { McpContext } from '../context.js';
import { McpError } from '../errors.js';
import { MAX_FILE_SIZE_BYTES } from '../limits.js';
import { safeConfigWrite } from '../writers/safe-config-write.js';
import { assertContainedPath } from '../writers/path-containment.js';
import { normalizeHooksRecord } from '../writers/normalize-hooks.js';
import { configSchema } from '../../config/core/schema.js';
import { parseMcp } from '../../canonical/features/mcp.js';
import type { McpConfig } from '../../core/mcp-types.js';

async function readYaml<T>(path: string): Promise<T | null> {
// Reject any config read/write whose resolved (symlink-followed) path escapes
// the project root — a symlinked config file OR a symlinked `.agentsmesh` parent
// dir must not leak or overwrite files outside the project. Anchoring at the
// project root (not `.agentsmesh`) is deliberate: a boundary of `.agentsmesh`
// would canonicalize through a symlinked `.agentsmesh` and cancel.
async function assertWithinProject(projectRoot: string, target: string): Promise<void> {
await assertContainedPath({
root: projectRoot,
target,
message: 'config path escapes project directory',
});
}

async function readYaml<T>(projectRoot: string, path: string): Promise<T | null> {
await assertWithinProject(projectRoot, path);
try {
return parseYaml(await readFile(path, 'utf8')) as T;
} catch (e: unknown) {
Expand All @@ -19,7 +34,8 @@ async function readYaml<T>(path: string): Promise<T | null> {
}
}

async function atomicWrite(path: string, content: string): Promise<void> {
async function atomicWrite(projectRoot: string, path: string, content: string): Promise<void> {
await assertWithinProject(projectRoot, path);
if (Buffer.byteLength(content, 'utf8') > MAX_FILE_SIZE_BYTES) {
throw new McpError('LIMIT_EXCEEDED', 'file exceeds 1 MiB cap');
}
Expand All @@ -32,13 +48,17 @@ export const settingsHandlers = {
// ─── reads ───

async getConfig(ctx: McpContext): Promise<unknown> {
const cfg = await readYaml<unknown>(resolve(ctx.projectRoot, 'agentsmesh.yaml'));
const cfg = await readYaml<unknown>(
ctx.projectRoot,
resolve(ctx.projectRoot, 'agentsmesh.yaml'),
);
if (cfg === null) throw new McpError('NO_PROJECT', 'agentsmesh.yaml missing');
return cfg;
},

async listMcpServers(ctx: McpContext): Promise<{ servers: McpConfig['mcpServers'] | null }> {
const path = resolve(ctx.projectRoot, '.agentsmesh/mcp.json');
await assertWithinProject(ctx.projectRoot, path);
try {
const cfg = await parseMcp(path);
return { servers: cfg?.mcpServers ?? null };
Expand All @@ -48,16 +68,23 @@ export const settingsHandlers = {
},

async getPermissions(ctx: McpContext): Promise<unknown> {
return (await readYaml(resolve(ctx.projectRoot, '.agentsmesh/permissions.yaml'))) ?? null;
return (
(await readYaml(ctx.projectRoot, resolve(ctx.projectRoot, '.agentsmesh/permissions.yaml'))) ??
null
);
},

async getHooks(ctx: McpContext): Promise<unknown> {
return (await readYaml(resolve(ctx.projectRoot, '.agentsmesh/hooks.yaml'))) ?? null;
return (
(await readYaml(ctx.projectRoot, resolve(ctx.projectRoot, '.agentsmesh/hooks.yaml'))) ?? null
);
},

async getIgnore(ctx: McpContext): Promise<{ patterns: string[] | null }> {
const file = resolve(ctx.projectRoot, '.agentsmesh/ignore');
await assertWithinProject(ctx.projectRoot, file);
try {
const src = await readFile(resolve(ctx.projectRoot, '.agentsmesh/ignore'), 'utf8');
const src = await readFile(file, 'utf8');
return { patterns: src.split(/\r?\n/).filter((l) => l !== '' && !l.startsWith('#')) };
} catch {
return { patterns: null };
Expand All @@ -78,7 +105,10 @@ export const settingsHandlers = {
},
): Promise<{ path: string; written: boolean }> {
const current =
(await readYaml<Record<string, unknown>>(resolve(ctx.projectRoot, 'agentsmesh.yaml'))) ?? {};
(await readYaml<Record<string, unknown>>(
ctx.projectRoot,
resolve(ctx.projectRoot, 'agentsmesh.yaml'),
)) ?? {};
const next: Record<string, unknown> = { ...current };
const apply = (k: 'targets' | 'features', v: string[] | undefined): void => {
if (v === undefined) return;
Expand Down Expand Up @@ -116,13 +146,14 @@ export const settingsHandlers = {
input: { name: string; server: Record<string, unknown>; dry_run?: boolean },
): Promise<{ path: string; written: boolean }> {
const path = resolve(ctx.projectRoot, '.agentsmesh/mcp.json');
await assertWithinProject(ctx.projectRoot, path);
const cfg = (await parseMcp(path).catch(() => null)) ?? { mcpServers: {} };
if (cfg.mcpServers[input.name] !== undefined) {
throw new McpError('ALREADY_EXISTS', `server "${input.name}" exists`);
}
cfg.mcpServers[input.name] = input.server as never;
if (input.dry_run === true) return { path, written: false };
await atomicWrite(path, JSON.stringify(cfg, null, 2) + '\n');
await atomicWrite(ctx.projectRoot, path, JSON.stringify(cfg, null, 2) + '\n');
return { path, written: true };
},

Expand All @@ -131,6 +162,7 @@ export const settingsHandlers = {
input: { name: string; server: Record<string, unknown>; merge?: boolean; dry_run?: boolean },
): Promise<{ path: string; written: boolean }> {
const path = resolve(ctx.projectRoot, '.agentsmesh/mcp.json');
await assertWithinProject(ctx.projectRoot, path);
const cfg = await parseMcp(path).catch(() => null);
if (cfg === null || cfg.mcpServers[input.name] === undefined) {
throw new McpError('NOT_FOUND', `server "${input.name}" not found`);
Expand All @@ -140,7 +172,7 @@ export const settingsHandlers = {
? ({ ...cfg.mcpServers[input.name], ...input.server } as never)
: (input.server as never);
if (input.dry_run === true) return { path, written: false };
await atomicWrite(path, JSON.stringify(cfg, null, 2) + '\n');
await atomicWrite(ctx.projectRoot, path, JSON.stringify(cfg, null, 2) + '\n');
return { path, written: true };
},

Expand All @@ -149,13 +181,14 @@ export const settingsHandlers = {
input: { name: string; dry_run?: boolean },
): Promise<{ path: string; removed: boolean }> {
const path = resolve(ctx.projectRoot, '.agentsmesh/mcp.json');
await assertWithinProject(ctx.projectRoot, path);
const cfg = await parseMcp(path).catch(() => null);
if (cfg === null || cfg.mcpServers[input.name] === undefined) {
throw new McpError('NOT_FOUND', `server "${input.name}" not found`);
}
delete cfg.mcpServers[input.name];
if (input.dry_run === true) return { path, removed: false };
await atomicWrite(path, JSON.stringify(cfg, null, 2) + '\n');
await atomicWrite(ctx.projectRoot, path, JSON.stringify(cfg, null, 2) + '\n');
return { path, removed: true };
},

Expand All @@ -171,6 +204,7 @@ export const settingsHandlers = {
): Promise<{ path: string; written: boolean }> {
const path = resolve(ctx.projectRoot, '.agentsmesh/permissions.yaml');
const current = (await readYaml<{ allow?: string[]; deny?: string[]; ask?: string[] }>(
ctx.projectRoot,
path,
)) ?? {
allow: [],
Expand All @@ -186,7 +220,7 @@ export const settingsHandlers = {
apply('deny', input.deny);
apply('ask', input.ask);
if (input.dry_run === true) return { path, written: false };
await atomicWrite(path, stringifyYaml(next));
await atomicWrite(ctx.projectRoot, path, stringifyYaml(next));
return { path, written: true };
},

Expand All @@ -198,7 +232,7 @@ export const settingsHandlers = {
if (input.dry_run === true) return { path, written: false };
// Flatten the nested native form to the flat canonical shape so parseHooks
// can recover it; a verbatim nested write is silently dropped on generate.
await atomicWrite(path, stringifyYaml(normalizeHooksRecord(input.hooks)));
await atomicWrite(ctx.projectRoot, path, stringifyYaml(normalizeHooksRecord(input.hooks)));
return { path, written: true };
},

Expand All @@ -209,13 +243,14 @@ export const settingsHandlers = {
const path = resolve(ctx.projectRoot, '.agentsmesh/ignore');
let next: string[];
if (input.mode === 'append') {
await assertWithinProject(ctx.projectRoot, path);
const cur = (await readFile(path, 'utf8').catch(() => '')).split(/\r?\n/).filter(Boolean);
next = Array.from(new Set([...cur, ...input.patterns]));
} else {
next = input.patterns;
}
if (input.dry_run === true) return { path, written: false };
await atomicWrite(path, next.join('\n') + '\n');
await atomicWrite(ctx.projectRoot, path, next.join('\n') + '\n');
return { path, written: true };
},
};
Loading
Loading