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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @copilotkit/pathfinder

## 1.15.2

### Patch Changes

- **Abuse blocklist**: hard-coded query-pattern blocklist short-circuits searches for off-topic terms confirmed via production analytics (box-office news, SCOTUS/Kalshi/CFTC certiorari, etc.). Matched queries return a structured 200 response with `blocked: true` and a teaching hint pointing at the server's actual domain (CopilotKit + AG-UI documentation). Zero false-positives on legitimate documentation queries.
- **`query_log` attribution**: added `client_ip`, `user_agent`, `blocked`, `block_reason` columns. Per-request IP/UA now stored alongside the query for cross-correlation; no more session-id join against external systems for IP attribution. Schema change is additive (nullable) — backward compatible.

## 1.15.1

### Patch 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": "@copilotkit/pathfinder",
"version": "1.15.1",
"version": "1.15.2",
"description": "The knowledge server for AI agents — index docs, code, Notion, Slack, and Discord into searchable, agent-accessible knowledge via MCP. Supports OpenAI, Ollama, and local transformers.js embeddings.",
"type": "module",
"repository": {
Expand Down
161 changes: 161 additions & 0 deletions src/__tests__/abuse-blocklist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect } from "vitest";
import { checkBlocklist } from "../mcp/abuse-blocklist.js";

// ---------------------------------------------------------------------------
// Positive matches — each pattern must catch its abuse string.
//
// Every pattern that ships in PATTERNS gets a positive test here. If a new
// pattern is added to the module without a corresponding positive test, the
// "every pattern has a positive test" sweep below will fail.
// ---------------------------------------------------------------------------

describe("checkBlocklist — positive matches", () => {
const positives: Array<{ name: string; reason: string; query: string }> = [
{
name: "movie-box-office",
reason: "pattern:movie-box-office",
query: "Toy Story 5 box office opening weekend",
},
{
name: "movie-box-office (no space)",
reason: "pattern:movie-box-office",
query: "boxoffice numbers for this weekend",
},
{
name: "toy-story-5",
reason: "pattern:toy-story-5",
query: "When does Toy Story 5 release in theaters",
},
{
name: "disclosure-day",
reason: "pattern:disclosure-day",
query: "What is Disclosure Day",
},
{
name: "obsession-2026",
reason: "pattern:obsession-2026",
query: "Obsession movie 2026 cast",
},
{
name: "obsession-2026 (no 'movie')",
reason: "pattern:obsession-2026",
query: "Obsession 2026 trailer",
},
{
name: "scary-movie-2026",
reason: "pattern:scary-movie-2026",
query: "Scary Movie 2026 release date",
},
{
name: "scotus-kalshi (scotus then kalshi)",
reason: "pattern:scotus-kalshi",
query: "SCOTUS denies Kalshi appeal in election prediction case",
},
{
name: "scotus-kalshi (kalshi then scotus)",
reason: "pattern:scotus-kalshi",
query: "Kalshi expects SCOTUS ruling next term",
},
{
name: "scotus-kalshi (cftc then certiorari)",
reason: "pattern:scotus-kalshi",
query: "CFTC certiorari petition status",
},
{
name: "sports-event-contracts",
reason: "pattern:sports-event-contracts",
query: "sports event contracts legality update",
},
{
name: "sports-event-contracts (singular sport)",
reason: "pattern:sports-event-contracts",
query: "sport event contract market",
},
];

for (const { name, reason, query } of positives) {
it(`matches: ${name}`, () => {
const result = checkBlocklist(query);
expect(result.matched).toBe(true);
expect(result.reason).toBe(reason);
});
}
});

// ---------------------------------------------------------------------------
// Negative matches — real CopilotKit / AG-UI documentation queries that
// contain near-miss phrasings must NOT trigger the blocklist. Each near-miss
// targets a specific pattern that could plausibly false-positive without the
// `\b` boundary or required co-occurrence — they're the regression guard for
// the "zero FP" guarantee in the module JSDoc.
// ---------------------------------------------------------------------------

describe("checkBlocklist — legitimate queries do not match", () => {
const negatives: string[] = [
// Near-miss for movie-box-office: contains "box" and "office" but not as
// an adjacent phrase. `\b...\b` + `\s*` between the two words gates this.
"useCopilotAction box for the office layout",
"how to render a checkbox in the office hours page",
// Near-miss for toy-story-5: contains the version-y "5" but no "toy story".
"version 5 of the agent toolkit",
// Near-miss for disclosure-day: "day" alone or "disclosure" in a privacy
// context. Pattern requires both adjacent.
"how to set disclosure on a tool result",
"what day does the agent run on",
// Near-miss for obsession-2026 / scary-movie-2026: bare "2026" must not
// match. The pattern requires the movie-title prefix.
"roadmap for 2026 release",
"agentic frameworks 2026 outlook",
// Near-miss for scotus-kalshi: "certiorari" alone, or "cftc" alone, in
// contexts that don't co-occur with the other half. The pattern requires
// BOTH a court/agency term AND a docket term.
"certiorari is a legal term but unrelated",
"what does CFTC stand for in documentation",
// Near-miss for sports-event-contracts: words present but not the phrase.
"sport in our user interface event contract",
// Plain CopilotKit / AG-UI documentation queries — these are the bread
// and butter of legitimate traffic; any false-positive here would be a
// direct user-visible regression.
"How do I install CopilotKit in a Next.js app",
"useCopilotAction onClick handler example",
"AG-UI event types for streaming responses",
"configure copilot runtime with anthropic",
"MCP server health endpoint",
"How to debug a langgraph agent",
];

for (const query of negatives) {
it(`does not match: "${query}"`, () => {
const result = checkBlocklist(query);
expect(result.matched).toBe(false);
expect(result.reason).toBeUndefined();
});
}
});

// ---------------------------------------------------------------------------
// Shape / API contract
// ---------------------------------------------------------------------------

describe("checkBlocklist — return shape", () => {
it("returns matched=false with no reason when nothing matches", () => {
const result = checkBlocklist("hello world");
expect(result).toEqual({ matched: false });
});

it("returns matched=true with a `pattern:` prefixed reason on match", () => {
const result = checkBlocklist("box office");
expect(result.matched).toBe(true);
expect(result.reason).toMatch(/^pattern:/);
});

it("is case-insensitive", () => {
expect(checkBlocklist("BOX OFFICE").matched).toBe(true);
expect(checkBlocklist("Box Office").matched).toBe(true);
expect(checkBlocklist("box office").matched).toBe(true);
});

it("returns matched=false for the empty string", () => {
expect(checkBlocklist("")).toEqual({ matched: false });
});
});
67 changes: 67 additions & 0 deletions src/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ describe("logQuery", () => {
"docs",
"sess-123",
"user",
// v1.15.2 attribution columns. The baseEntry above doesn't carry
// client_ip/user_agent/blocked/block_reason, so the writer persists the
// documented defaults: null for the two optional strings, false for the
// blocked flag, and null for the reason. Pinning these explicitly so any
// future drift in the column order or default coercion fails loudly.
null,
null,
false,
null,
]);
});

Expand All @@ -87,6 +96,11 @@ describe("logQuery", () => {
baseEntry.source_name,
baseEntry.session_id,
baseEntry.request_source,
// v1.15.2 attribution columns (see non-redacted path above).
null,
null,
false,
null,
]);
// And pin the literal so the constant can never silently drift to a
// different sentinel that downstream reads wouldn't recognize.
Expand Down Expand Up @@ -146,6 +160,59 @@ describe("logQuery", () => {
const [, params] = mockQuery.mock.calls[0];
expect(params[7]).toBe("synthetic");
});

// v1.15.2 attribution columns: client_ip, user_agent, blocked, block_reason.
// Positional indices on params are 8, 9, 10, 11 respectively (after the
// existing 8 fields tool_name..request_source).

it("persists client_ip and user_agent verbatim when provided", async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await logQuery({
...baseEntry,
client_ip: "203.0.113.10",
user_agent: "Claude-User/1.0",
});

const [, params] = mockQuery.mock.calls[0];
expect(params[8]).toBe("203.0.113.10");
expect(params[9]).toBe("Claude-User/1.0");
});

it("truncates a pathological user_agent to USER_AGENT_MAX_LEN chars", async () => {
// Defensive truncation guards against a malicious / runaway UA bloating
// the row. Pinning the cap here so any future change to USER_AGENT_MAX_LEN
// is a deliberate, test-visible decision.
mockQuery.mockResolvedValueOnce({ rows: [] });
const huge = "x".repeat(2048);
await logQuery({ ...baseEntry, user_agent: huge });

const [, params] = mockQuery.mock.calls[0];
expect((params[9] as string).length).toBe(256);
});

it("persists blocked=true with a block_reason", async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await logQuery({
...baseEntry,
blocked: true,
block_reason: "pattern:movie-box-office",
});

const [, params] = mockQuery.mock.calls[0];
expect(params[10]).toBe(true);
expect(params[11]).toBe("pattern:movie-box-office");
});

it("defaults blocked to false and block_reason to null when absent", async () => {
// The writer normalizes missing attribution fields so callers that
// pre-date v1.15.2 (or simply don't care) get back-compatible behavior.
mockQuery.mockResolvedValueOnce({ rows: [] });
await logQuery(baseEntry);

const [, params] = mockQuery.mock.calls[0];
expect(params[10]).toBe(false);
expect(params[11]).toBeNull();
});
});

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const program = new Command();
program
.name("pathfinder")
.description("The knowledge server for AI agents")
.version("1.15.1");
.version("1.15.2");

program
.command("init")
Expand Down
51 changes: 49 additions & 2 deletions src/db/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,39 @@ export interface QueryLogEntry {
* so the persisted column is always a known value for new rows.
*/
request_source?: RequestSource | string | null;
/**
* Per-request client IP at MCP-session init, resolved via the same trust-
* proxy boundary as {@link oauthClientIp} / {@link clientIp}. Optional so
* existing call sites compile unchanged; absent values persist as NULL.
*/
client_ip?: string | null;
/**
* Per-request User-Agent at MCP-session init, truncated to
* {@link USER_AGENT_MAX_LEN} chars at the write boundary to bound row size.
*/
user_agent?: string | null;
/**
* Whether the abuse blocklist short-circuited the query. Defaults to false
* at the write boundary so call sites that don't pass it compile and read
* back as "not blocked".
*/
blocked?: boolean;
/**
* Free-form reason string when {@link blocked} is true (e.g. a
* `pattern:<name>` tag from the abuse blocklist). NULL when not blocked.
*/
block_reason?: string | null;
}

/**
* Hard cap on `user_agent` storage to bound row size. Pathfinder doesn't need
* the full UA for any analytics workflow — first 256 chars is more than enough
* to distinguish Claude-User, browsers, curl, and the common bots. Truncating
* at the write boundary keeps a runaway / pathological UA header from bloating
* the column.
*/
export const USER_AGENT_MAX_LEN = 256;

export interface AnalyticsSummary {
total_queries: number;
total_queries_window: number;
Expand Down Expand Up @@ -275,10 +306,22 @@ export async function logQuery(
// written before this column existed stay NULL and are read back as real
// users by the analytics layer.
const requestSource = normalizeRequestSource(entry.request_source);
// Defensive truncation at the write boundary so a pathological UA header
// can't bloat the row. See USER_AGENT_MAX_LEN. `null`/`undefined` pass
// through unchanged; `slice` on a string is safe for non-ASCII too because
// it operates on UTF-16 code units, not bytes, so a 256-cap always fits
// any DB text column with reasonable headroom.
const userAgent =
typeof entry.user_agent === "string"
? entry.user_agent.slice(0, USER_AGENT_MAX_LEN)
: (entry.user_agent ?? null);
const blocked = entry.blocked ?? false;
const blockReason = entry.block_reason ?? null;
const clientIp = entry.client_ip ?? null;
try {
await pool.query(
`INSERT INTO query_log (tool_name, query_text, result_count, top_score, latency_ms, source_name, session_id, request_source)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
`INSERT INTO query_log (tool_name, query_text, result_count, top_score, latency_ms, source_name, session_id, request_source, client_ip, user_agent, blocked, block_reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
entry.tool_name,
text,
Expand All @@ -288,6 +331,10 @@ export async function logQuery(
entry.source_name,
entry.session_id,
requestSource,
clientIp,
userAgent,
blocked,
blockReason,
],
);
} catch (err) {
Expand Down
18 changes: 18 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ CREATE TABLE IF NOT EXISTS query_log (
source_name TEXT,
session_id TEXT,
request_source TEXT,
client_ip TEXT,
user_agent TEXT,
blocked BOOLEAN NOT NULL DEFAULT FALSE,
block_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Expand All @@ -122,6 +126,20 @@ CREATE INDEX IF NOT EXISTS idx_query_log_tool_name ON query_log (tool_name);
ALTER TABLE query_log ADD COLUMN IF NOT EXISTS request_source TEXT;
CREATE INDEX IF NOT EXISTS idx_query_log_request_source ON query_log (request_source);

-- Per-request attribution columns (v1.15.2). Added so cross-correlation against
-- an external system (PostHog, etc.) by session-id-prefix is no longer required
-- to answer "which IP/UA sent this query". All four columns are additive and
-- nullable / defaulted, so the migration is backward compatible for installs
-- whose query_log predates v1.15.2. The CREATE TABLE above carries them
-- verbatim for fresh installs. The blocked column defaults to FALSE so
-- historical rows read back as "not blocked" without a backfill.
ALTER TABLE query_log ADD COLUMN IF NOT EXISTS client_ip TEXT;
ALTER TABLE query_log ADD COLUMN IF NOT EXISTS user_agent TEXT;
ALTER TABLE query_log ADD COLUMN IF NOT EXISTS blocked BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE query_log ADD COLUMN IF NOT EXISTS block_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_query_log_blocked ON query_log (blocked);
CREATE INDEX IF NOT EXISTS idx_query_log_client_ip ON query_log (client_ip);

-- Webhook delivery tracking
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id SERIAL PRIMARY KEY,
Expand Down
Loading