stagent 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app/api/channels/[id]/route.ts +3 -2
- package/src/app/api/channels/inbound/slack/route.ts +9 -2
- package/src/app/api/channels/inbound/telegram/poll/route.ts +12 -0
- package/src/app/api/channels/inbound/telegram/route.ts +12 -1
- package/src/app/api/channels/route.ts +3 -2
- package/src/app/api/data/clear/route.ts +4 -0
- package/src/app/api/data/seed/route.ts +4 -0
- package/src/app/api/documents/route.ts +36 -6
- package/src/app/api/tasks/[id]/respond/route.ts +23 -1
- package/src/lib/agents/claude-agent.ts +1 -1
- package/src/lib/channels/types.ts +32 -0
- package/src/lib/db/schema.ts +4 -1
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { db } from "@/lib/db";
|
|
3
3
|
import { channelConfigs } from "@/lib/db/schema";
|
|
4
4
|
import { eq } from "drizzle-orm";
|
|
5
|
+
import { maskChannelRow } from "@/lib/channels/types";
|
|
5
6
|
|
|
6
7
|
export async function GET(
|
|
7
8
|
_req: NextRequest,
|
|
@@ -18,7 +19,7 @@ export async function GET(
|
|
|
18
19
|
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
return NextResponse.json(channel);
|
|
22
|
+
return NextResponse.json(maskChannelRow(channel));
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export async function PATCH(
|
|
@@ -79,7 +80,7 @@ export async function PATCH(
|
|
|
79
80
|
.from(channelConfigs)
|
|
80
81
|
.where(eq(channelConfigs.id, id));
|
|
81
82
|
|
|
82
|
-
return NextResponse.json(updated);
|
|
83
|
+
return NextResponse.json(maskChannelRow(updated));
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export async function DELETE(
|
|
@@ -56,8 +56,15 @@ export async function POST(req: NextRequest) {
|
|
|
56
56
|
return NextResponse.json({ error: "Invalid channel config" }, { status: 500 });
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
if (parsedConfig.signingSecret
|
|
59
|
+
// Require signingSecret — refuse to process inbound messages without signature verification
|
|
60
|
+
if (!parsedConfig.signingSecret) {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{ error: "Channel config missing signingSecret — cannot verify request" },
|
|
63
|
+
{ status: 401 }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (slackAdapter.verifySignature) {
|
|
61
68
|
const headers: Record<string, string> = {};
|
|
62
69
|
req.headers.forEach((value, key) => {
|
|
63
70
|
headers[key] = value;
|
|
@@ -15,6 +15,13 @@ import { handleInboundMessage } from "@/lib/channels/gateway";
|
|
|
15
15
|
* Returns the number of updates processed.
|
|
16
16
|
*/
|
|
17
17
|
export async function POST(req: NextRequest) {
|
|
18
|
+
// Guard: only accept requests from the internal poller.
|
|
19
|
+
// The internal scheduler sets this header; external callers won't have it.
|
|
20
|
+
const internalToken = req.headers.get("x-stagent-internal");
|
|
21
|
+
if (internalToken !== "poll") {
|
|
22
|
+
return NextResponse.json({ error: "Unauthorized — internal use only" }, { status: 401 });
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
const configId = req.nextUrl.searchParams.get("configId");
|
|
19
26
|
if (!configId) {
|
|
20
27
|
return NextResponse.json({ error: "Missing configId" }, { status: 400 });
|
|
@@ -30,6 +37,11 @@ export async function POST(req: NextRequest) {
|
|
|
30
37
|
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
// Refuse to poll disabled channels
|
|
41
|
+
if (config.status !== "active") {
|
|
42
|
+
return NextResponse.json({ error: "Channel is not active" }, { status: 403 });
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
let parsedConfig: Record<string, unknown>;
|
|
34
46
|
try {
|
|
35
47
|
parsedConfig = JSON.parse(config.config) as Record<string, unknown>;
|
|
@@ -39,8 +39,19 @@ export async function POST(req: NextRequest) {
|
|
|
39
39
|
return NextResponse.json({ error: "Invalid channel config" }, { status: 500 });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Require webhookSecret — refuse to process inbound messages without authentication
|
|
42
43
|
const expectedSecret = parsedConfig.webhookSecret as string | undefined;
|
|
43
|
-
if (expectedSecret
|
|
44
|
+
if (!expectedSecret) {
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ error: "Channel config missing webhookSecret — cannot verify request" },
|
|
47
|
+
{ status: 401 }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// NOTE: The secret is passed as a query-string parameter. This is Telegram's recommended
|
|
52
|
+
// webhook verification design (https://core.telegram.org/bots/api#setwebhook secret_token).
|
|
53
|
+
// Query strings may appear in server access logs — ensure logs are access-controlled.
|
|
54
|
+
if (secret !== expectedSecret) {
|
|
44
55
|
return NextResponse.json({ error: "Invalid secret" }, { status: 403 });
|
|
45
56
|
}
|
|
46
57
|
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { db } from "@/lib/db";
|
|
3
3
|
import { channelConfigs } from "@/lib/db/schema";
|
|
4
4
|
import { desc, eq } from "drizzle-orm";
|
|
5
|
+
import { maskChannelRow } from "@/lib/channels/types";
|
|
5
6
|
|
|
6
7
|
const VALID_CHANNEL_TYPES = ["slack", "telegram", "webhook"] as const;
|
|
7
8
|
|
|
@@ -11,7 +12,7 @@ export async function GET() {
|
|
|
11
12
|
.from(channelConfigs)
|
|
12
13
|
.orderBy(desc(channelConfigs.createdAt));
|
|
13
14
|
|
|
14
|
-
return NextResponse.json(result);
|
|
15
|
+
return NextResponse.json(result.map(maskChannelRow));
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export async function POST(req: NextRequest) {
|
|
@@ -67,5 +68,5 @@ export async function POST(req: NextRequest) {
|
|
|
67
68
|
.from(channelConfigs)
|
|
68
69
|
.where(eq(channelConfigs.id, id));
|
|
69
70
|
|
|
70
|
-
return NextResponse.json(created, { status: 201 });
|
|
71
|
+
return NextResponse.json(maskChannelRow(created), { status: 201 });
|
|
71
72
|
}
|
|
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { clearAllData } from "@/lib/data/clear";
|
|
3
3
|
|
|
4
4
|
export async function POST() {
|
|
5
|
+
if (process.env.NODE_ENV === "production") {
|
|
6
|
+
return NextResponse.json(null, { status: 404 });
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
try {
|
|
6
10
|
const deleted = clearAllData();
|
|
7
11
|
return NextResponse.json({ success: true, deleted });
|
|
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { seedSampleData } from "@/lib/data/seed";
|
|
3
3
|
|
|
4
4
|
export async function POST() {
|
|
5
|
+
if (process.env.NODE_ENV === "production") {
|
|
6
|
+
return NextResponse.json(null, { status: 404 });
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
try {
|
|
6
10
|
const seeded = await seedSampleData();
|
|
7
11
|
return NextResponse.json({ success: true, seeded });
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { db } from "@/lib/db";
|
|
3
3
|
import { documents, tasks, projects } from "@/lib/db/schema";
|
|
4
|
-
import { eq, and, like, or, desc
|
|
4
|
+
import { eq, and, like, or, desc } from "drizzle-orm";
|
|
5
5
|
import { access, stat, copyFile, mkdir } from "fs/promises";
|
|
6
|
-
import { basename, extname, join } from "path";
|
|
6
|
+
import path, { basename, extname, join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
7
8
|
import crypto from "crypto";
|
|
8
9
|
import { getStagentUploadsDir } from "@/lib/utils/stagent-paths";
|
|
9
10
|
import { processDocument } from "@/lib/documents/processor";
|
|
@@ -120,18 +121,47 @@ export async function POST(req: NextRequest) {
|
|
|
120
121
|
|
|
121
122
|
const body = parsed.data;
|
|
122
123
|
|
|
124
|
+
// Path traversal protection: resolve and validate the file path
|
|
125
|
+
const resolvedPath = path.resolve(body.file_path);
|
|
126
|
+
const home = homedir();
|
|
127
|
+
const SENSITIVE_PREFIXES = ["/etc", "/var", "/proc", "/sys", "/dev", "/root"];
|
|
128
|
+
const SENSITIVE_HOME_DIRS = [".ssh", ".gnupg", ".aws", ".config", ".env"];
|
|
129
|
+
|
|
130
|
+
if (SENSITIVE_PREFIXES.some((prefix) => resolvedPath.startsWith(prefix))) {
|
|
131
|
+
return NextResponse.json(
|
|
132
|
+
{ error: "Access denied: path points to a restricted system directory" },
|
|
133
|
+
{ status: 403 }
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (resolvedPath.startsWith(home)) {
|
|
138
|
+
const relativeToHome = resolvedPath.slice(home.length + 1);
|
|
139
|
+
if (SENSITIVE_HOME_DIRS.some((dir) => relativeToHome.startsWith(dir))) {
|
|
140
|
+
return NextResponse.json(
|
|
141
|
+
{ error: "Access denied: path points to a sensitive home directory" },
|
|
142
|
+
{ status: 403 }
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} else if (!resolvedPath.startsWith("/tmp")) {
|
|
146
|
+
// Outside home and not /tmp — reject
|
|
147
|
+
return NextResponse.json(
|
|
148
|
+
{ error: "Access denied: path must be under the user's home directory or /tmp" },
|
|
149
|
+
{ status: 403 }
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
123
153
|
try {
|
|
124
|
-
await access(
|
|
154
|
+
await access(resolvedPath);
|
|
125
155
|
} catch {
|
|
126
156
|
return NextResponse.json({ error: `File not found: ${body.file_path}` }, { status: 400 });
|
|
127
157
|
}
|
|
128
158
|
|
|
129
|
-
const stats = await stat(
|
|
159
|
+
const stats = await stat(resolvedPath);
|
|
130
160
|
if (!stats.isFile()) {
|
|
131
161
|
return NextResponse.json({ error: `Not a file: ${body.file_path}` }, { status: 400 });
|
|
132
162
|
}
|
|
133
163
|
|
|
134
|
-
const originalName = basename(
|
|
164
|
+
const originalName = basename(resolvedPath);
|
|
135
165
|
const ext = extname(originalName).toLowerCase();
|
|
136
166
|
const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
137
167
|
const id = crypto.randomUUID();
|
|
@@ -140,7 +170,7 @@ export async function POST(req: NextRequest) {
|
|
|
140
170
|
const uploadsDir = getStagentUploadsDir();
|
|
141
171
|
await mkdir(uploadsDir, { recursive: true });
|
|
142
172
|
const storagePath = join(uploadsDir, filename);
|
|
143
|
-
await copyFile(
|
|
173
|
+
await copyFile(resolvedPath, storagePath);
|
|
144
174
|
|
|
145
175
|
const now = new Date();
|
|
146
176
|
await db.insert(documents).values({
|
|
@@ -43,8 +43,30 @@ export async function POST(
|
|
|
43
43
|
return NextResponse.json({ error: "Already responded" }, { status: 409 });
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Validate updatedInput keys against the original tool input to prevent injection
|
|
47
|
+
let sanitizedUpdatedInput = updatedInput;
|
|
48
|
+
if (updatedInput !== undefined && updatedInput !== null && typeof updatedInput === "object" && !Array.isArray(updatedInput)) {
|
|
49
|
+
try {
|
|
50
|
+
const originalToolInput = typeof notification.toolInput === "string" ? JSON.parse(notification.toolInput) : (notification.toolInput ?? {});
|
|
51
|
+
if (typeof originalToolInput === "object" && originalToolInput !== null) {
|
|
52
|
+
const allowedKeys = new Set(Object.keys(originalToolInput));
|
|
53
|
+
const inputRecord = updatedInput as Record<string, unknown>;
|
|
54
|
+
const extraKeys = Object.keys(inputRecord).filter((k) => !allowedKeys.has(k));
|
|
55
|
+
if (extraKeys.length > 0) {
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{ error: `updatedInput contains disallowed keys: ${extraKeys.join(", ")}` },
|
|
58
|
+
{ status: 400 }
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// If we can't parse the original notification data, reject updatedInput entirely
|
|
64
|
+
sanitizedUpdatedInput = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
// Write response — the polling loop in claude-agent.ts will detect this
|
|
47
|
-
const responseData = { behavior, message, updatedInput, alwaysAllow };
|
|
69
|
+
const responseData = { behavior, message, updatedInput: sanitizedUpdatedInput, alwaysAllow };
|
|
48
70
|
await db
|
|
49
71
|
.update(notifications)
|
|
50
72
|
.set({
|
|
@@ -372,7 +372,7 @@ export async function buildTaskQueryContext(
|
|
|
372
372
|
const outputInstructions = buildTaskOutputInstructions(task.id);
|
|
373
373
|
const learnedCtx = getActiveLearnedContext(profileId);
|
|
374
374
|
const learnedCtxBlock = learnedCtx
|
|
375
|
-
? `## Learned Context\
|
|
375
|
+
? `## Learned Context\n<learned-context>\n${learnedCtx}\n</learned-context>`
|
|
376
376
|
: "";
|
|
377
377
|
|
|
378
378
|
// Resolve working directory: project's workingDirectory > launch cwd
|
|
@@ -30,6 +30,38 @@ export interface ChannelDeliveryResult {
|
|
|
30
30
|
error?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// ── Credential masking ───────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Fields in channel config JSON that contain secrets and must be masked in API responses. */
|
|
36
|
+
const SENSITIVE_CONFIG_KEYS = ["botToken", "signingSecret", "webhookSecret"];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mask sensitive fields in a channel config JSON string.
|
|
40
|
+
* Returns a new JSON string with secrets replaced by "****<last4>".
|
|
41
|
+
*/
|
|
42
|
+
export function maskChannelConfig(configJson: string): string {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(configJson) as Record<string, unknown>;
|
|
45
|
+
for (const key of SENSITIVE_CONFIG_KEYS) {
|
|
46
|
+
const val = parsed[key];
|
|
47
|
+
if (typeof val === "string" && val.length > 0) {
|
|
48
|
+
const last4 = val.slice(-4);
|
|
49
|
+
parsed[key] = `****${last4}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return JSON.stringify(parsed);
|
|
53
|
+
} catch {
|
|
54
|
+
return configJson;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Mask sensitive fields in a channel config row before returning from API.
|
|
60
|
+
*/
|
|
61
|
+
export function maskChannelRow<T extends { config: string }>(row: T): T {
|
|
62
|
+
return { ...row, config: maskChannelConfig(row.config) };
|
|
63
|
+
}
|
|
64
|
+
|
|
33
65
|
/** Normalized inbound message from any channel. */
|
|
34
66
|
export interface InboundMessage {
|
|
35
67
|
text: string;
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -604,7 +604,10 @@ export const channelConfigs = sqliteTable(
|
|
|
604
604
|
id: text("id").primaryKey(),
|
|
605
605
|
channelType: text("channel_type", { enum: ["slack", "telegram", "webhook"] }).notNull(),
|
|
606
606
|
name: text("name").notNull(),
|
|
607
|
-
|
|
607
|
+
// SECURITY: The config JSON contains credentials (botToken, signingSecret, webhookSecret)
|
|
608
|
+
// stored as plaintext. A future improvement should encrypt these at rest.
|
|
609
|
+
// All API responses MUST mask sensitive fields via maskChannelConfig() before returning.
|
|
610
|
+
config: text("config").notNull(), // JSON: { webhookUrl?, botToken?, chatId?, channelId?, signingSecret?, webhookSecret? }
|
|
608
611
|
status: text("status", { enum: ["active", "disabled"] }).default("active").notNull(),
|
|
609
612
|
testStatus: text("test_status", { enum: ["untested", "ok", "failed"] }).default("untested").notNull(),
|
|
610
613
|
direction: text("direction", { enum: ["outbound", "bidirectional"] }).default("outbound").notNull(),
|