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
8 changes: 8 additions & 0 deletions bun.lock

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

2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"msgpackr": "^1.11.8",
"noise-handshake": "^4.2.0",
"react": "^18.3.1",
"safe-regex": "^2.1.1",
"snooplogg": "^6.1.1",
"ws": "^8.19.0",
"zod": "^3.24.0"
Expand All @@ -63,6 +64,7 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/safe-regex": "^1.1.6",
"fast-check": "^4.5.3",
"typescript": "^5.7.0",
"@tpsdev-ai/agent": "workspace:*"
Expand Down
30 changes: 22 additions & 8 deletions packages/cli/src/schema/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { z } from "zod";
import safeRegex from "safe-regex";

const RegexStringSchema = z.string().refine((val) => {
try {
new RegExp(val); // nosemgrep: detect-non-literal-regexp — validating user-provided pattern from config
return true;
} catch {
return false;
}
}, { message: "Invalid regular expression" });
// Patterns come from tps.yaml manifests and are evaluated against inbound mail
// bodies. Compiling a pattern only proves it is well-formed — not that its
// evaluation cost is bounded by input length. We therefore validate on two
// axes so "validated" means "safe to run", not merely "parses":
// 1. it compiles to a RegExp, and
// 2. safeRegex accepts it (keeps match cost bounded relative to input).
// Manifests carrying a rejected pattern fail schema validation and are dropped,
// so a rejected pattern never reaches the runtime match sites.
const RegexStringSchema = z
.string()
.refine((val) => {
try {
new RegExp(val); // nosemgrep: detect-non-literal-regexp — validating a config-provided pattern for well-formedness
return true;
} catch {
return false;
}
}, { message: "Invalid regular expression" })
.refine((val) => safeRegex(val), {
message: "Regular expression pattern rejected by safety validation",
});

export const MailHandlerMatchSchema = z.object({
from: z.array(z.string()).optional(),
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/mail-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function runHandlerPipeline(
if (manifest.routing) {
for (const rule of manifest.routing) {
try {
const re = new RegExp(rule.pattern); // nosemgrep: detect-non-literal-regexp — pattern from validated tps.yaml config
const re = new RegExp(rule.pattern); // nosemgrep: detect-non-literal-regexp — pattern safety-validated at schema load (RegexStringSchema)
if (re.test(bodyToTest)) {
return { type: "forward", to: rule.to, body: msg.body };
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export function matchesFilter(
}

if (filter.bodyPattern) {
const re = new RegExp(filter.bodyPattern); // nosemgrep: detect-non-literal-regexp — pattern from validated tps.yaml config
const re = new RegExp(filter.bodyPattern); // nosemgrep: detect-non-literal-regexp — pattern safety-validated at schema load (RegexStringSchema)
const bodyToTest = msg.body.trim().slice(0, 1024); // Capped at 1024 (S15)
if (!re.test(bodyToTest)) return false;
}
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/utils/task-result-mail.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
// Escape regex metacharacters so an interpolated string is matched literally.
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function formatTaskCompleteMailBody(summary: string, prefix = "Task complete"): string {
const trimmedSummary = summary.trimStart();
const prefixPattern = new RegExp(`^${prefix}:?(?:\\r?\\n\\s*|\\s+)`, "i");
// `prefix` is interpolated into a pattern, so it must be escaped — otherwise
// metacharacters in the prefix (e.g. parentheses in "Task complete (via …)")
// are interpreted as regex syntax and the leading-prefix strip silently
// misbehaves. Escaping keeps the match strictly literal.
const prefixPattern = new RegExp(`^${escapeRegex(prefix)}:?(?:\\r?\\n\\s*|\\s+)`, "i"); // nosemgrep: detect-non-literal-regexp — prefix escaped above
const normalizedSummary = trimmedSummary.replace(prefixPattern, "");
return `${prefix}:\n\n${normalizedSummary}`;
}
52 changes: 52 additions & 0 deletions packages/cli/test/manifest-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,56 @@ describe("TpsYamlSchema", () => {
});
expect(res.success).toBe(true);
});

test("accepts ordinary match and routing patterns", () => {
const res = TpsYamlSchema.safeParse({
name: "agent-1",
capabilities: {
mail_handler: { match: { bodyPattern: "^deploy\\b.*(prod|staging)" } }
},
routing: [
{ pattern: "urgent|priority", to: "other-agent" },
{ pattern: "a{1,50}", to: "third-agent" }
]
});
expect(res.success).toBe(true);
});

test("rejects an unsafe routing pattern at schema validation", () => {
// A pattern that compiles fine but whose match cost is not bounded by input
// length must be refused before it can ever be handed to new RegExp().test().
const res = TpsYamlSchema.safeParse({
name: "agent-1",
routing: [
{ pattern: "(a+)+$", to: "other-agent" }
]
});
expect(res.success).toBe(false);
});

test("rejects an unsafe body match pattern at schema validation", () => {
const res = TpsYamlSchema.safeParse({
name: "agent-1",
capabilities: {
mail_handler: { match: { bodyPattern: "([a-zA-Z]+)*$" } }
}
});
expect(res.success).toBe(false);
});

test("pattern validation is bounded — settles quickly", () => {
// Before schema-time validation, such a pattern would be accepted and only
// exercised much later during matching. Validation must settle well under a
// small time bound rather than getting stuck evaluating the pattern.
const start = Date.now();
const res = TpsYamlSchema.safeParse({
name: "agent-1",
routing: [
{ pattern: "(.*a){25}", to: "other-agent" }
]
});
const elapsedMs = Date.now() - start;
expect(res.success).toBe(false);
expect(elapsedMs).toBeLessThan(500);
});
});
22 changes: 22 additions & 0 deletions packages/cli/test/task-result-mail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,26 @@ describe("formatTaskCompleteMailBody", () => {
"Task complete:\n\nShipped the fix",
);
});

test("treats regex metacharacters in the prefix literally", () => {
// With prefix "a.c" the leading-prefix strip must only fire on a literal
// "a.c", never on "abc" (which it would if "." were treated as regex "any").
expect(formatTaskCompleteMailBody("abc: shipped", "a.c")).toBe(
"a.c:\n\nabc: shipped",
);
expect(formatTaskCompleteMailBody("a.c: shipped", "a.c")).toBe(
"a.c:\n\nshipped",
);
});

test("strips a metacharacter-bearing prefix used by a real caller", () => {
// The "(via Flair)" caller carries parentheses; the strip must match them
// literally and de-duplicate the leading prefix as intended.
expect(
formatTaskCompleteMailBody(
"Task complete (via Flair): done",
"Task complete (via Flair)",
),
).toBe("Task complete (via Flair):\n\ndone");
});
});
Loading