@cursor/july 0.1.27 → 0.1.28

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.
Files changed (122) hide show
  1. package/dist/bin/agent-serve.js +12 -3
  2. package/dist/channels/slack/auth.d.ts +13 -13
  3. package/dist/channels/slack/auth.d.ts.map +1 -1
  4. package/dist/channels/slack/auth.js +19 -10
  5. package/dist/channels/slack/cursor-account.d.ts.map +1 -1
  6. package/dist/channels/slack/cursor-account.js +3 -1
  7. package/dist/channels/slack/defaults.d.ts +7 -4
  8. package/dist/channels/slack/defaults.d.ts.map +1 -1
  9. package/dist/channels/slack/defaults.js +14 -4
  10. package/dist/channels/slack/dispatch.d.ts +6 -0
  11. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  12. package/dist/channels/slack/dispatch.js +59 -21
  13. package/dist/channels/slack/external-policy.d.ts +27 -0
  14. package/dist/channels/slack/external-policy.d.ts.map +1 -0
  15. package/dist/channels/slack/external-policy.js +53 -0
  16. package/dist/channels/slack/index.d.ts +1 -0
  17. package/dist/channels/slack/index.d.ts.map +1 -1
  18. package/dist/channels/slack/index.js +1 -0
  19. package/dist/channels/slack/interactive.d.ts +6 -0
  20. package/dist/channels/slack/interactive.d.ts.map +1 -1
  21. package/dist/channels/slack/interactive.js +42 -15
  22. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  23. package/dist/channels/slack/slack-channel.js +19 -6
  24. package/dist/channels/slack/types.d.ts +10 -0
  25. package/dist/channels/slack/types.d.ts.map +1 -1
  26. package/dist/docs/404.html +2 -2
  27. package/dist/docs/ab.html +3 -3
  28. package/dist/docs/assets/{app.BICLoJYF.js → app.CqjrYaT2.js} +1 -1
  29. package/dist/docs/assets/chunks/@localSearchIndexroot.VWQoPFJ1.js +1 -0
  30. package/dist/docs/assets/chunks/{VPLocalSearchBox.wN4Ko9dT.js → VPLocalSearchBox.ag-MwUel.js} +1 -1
  31. package/dist/docs/assets/chunks/{theme.dnnTE-Co.js → theme.BS-DJWat.js} +2 -2
  32. package/dist/docs/assets/{guides_slack.md.D_xFKlnF.js → guides_slack.md.CjmJSvZS.js} +8 -4
  33. package/dist/docs/assets/guides_slack.md.CjmJSvZS.lean.js +1 -0
  34. package/dist/docs/assets/guides_webhooks.md.wSOYas3X.js +183 -0
  35. package/dist/docs/assets/{guides_webhooks.md.B1EswtUu.lean.js → guides_webhooks.md.wSOYas3X.lean.js} +1 -1
  36. package/dist/docs/assets/{index.md.Dfv5ic9t.js → index.md.CH_s5uZe.js} +1 -1
  37. package/dist/docs/assets/{reference_channels.md.DdmiKgqf.js → reference_channels.md.CDhTRfUz.js} +2 -2
  38. package/dist/docs/assets/{reference_channels.md.DdmiKgqf.lean.js → reference_channels.md.CDhTRfUz.lean.js} +1 -1
  39. package/dist/docs/building-with-agents.html +3 -3
  40. package/dist/docs/concepts.html +3 -3
  41. package/dist/docs/deployment.html +3 -3
  42. package/dist/docs/evals.html +3 -3
  43. package/dist/docs/example-agents/approval-buddy.html +3 -3
  44. package/dist/docs/example-agents/benny.html +3 -3
  45. package/dist/docs/example-agents/bugbot.html +3 -3
  46. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  47. package/dist/docs/example-agents/codeowners-review.html +3 -3
  48. package/dist/docs/example-agents/concierge.html +3 -3
  49. package/dist/docs/example-agents/fsd.html +3 -3
  50. package/dist/docs/example-agents/index.html +3 -3
  51. package/dist/docs/example-agents/knowledge-base.html +3 -3
  52. package/dist/docs/example-agents/oncall.html +3 -3
  53. package/dist/docs/example-agents/security-reviewer.html +3 -3
  54. package/dist/docs/example-agents/slack-agent.html +3 -3
  55. package/dist/docs/example-agents/weather-agent.html +3 -3
  56. package/dist/docs/guides/agent-to-agent.html +3 -3
  57. package/dist/docs/guides/cloud-runtime.html +3 -3
  58. package/dist/docs/guides/github.html +3 -3
  59. package/dist/docs/guides/human-in-the-loop.html +3 -3
  60. package/dist/docs/guides/mcp-oauth.html +3 -3
  61. package/dist/docs/guides/slack.html +11 -7
  62. package/dist/docs/guides/webhooks.html +138 -5
  63. package/dist/docs/hashmap.json +1 -1
  64. package/dist/docs/hillclimbing.html +3 -3
  65. package/dist/docs/index.html +5 -5
  66. package/dist/docs/quickstart.html +3 -3
  67. package/dist/docs/reference/agent-config.html +3 -3
  68. package/dist/docs/reference/channels.html +5 -5
  69. package/dist/docs/reference/cli.html +3 -3
  70. package/dist/docs/reference/connections.html +3 -3
  71. package/dist/docs/reference/hooks.html +3 -3
  72. package/dist/docs/reference/http-api.html +3 -3
  73. package/dist/docs/reference/instructions.html +3 -3
  74. package/dist/docs/reference/playground.html +3 -3
  75. package/dist/docs/reference/project-layout.html +3 -3
  76. package/dist/docs/reference/prompt.html +3 -3
  77. package/dist/docs/reference/schedules.html +3 -3
  78. package/dist/docs/reference/sessions.html +3 -3
  79. package/dist/docs/reference/skills.html +3 -3
  80. package/dist/docs/reference/subagents.html +3 -3
  81. package/dist/docs/reference/tools.html +3 -3
  82. package/dist/docs/scaffolding-agents.html +3 -3
  83. package/dist/docs/storage.html +3 -3
  84. package/dist/docs/troubleshooting.html +3 -3
  85. package/dist/internal/cli-ax.d.ts +8 -0
  86. package/dist/internal/cli-ax.d.ts.map +1 -1
  87. package/dist/internal/cli-ax.js +116 -57
  88. package/dist/internal/cursor-slack-relay.d.ts +2 -0
  89. package/dist/internal/cursor-slack-relay.d.ts.map +1 -1
  90. package/dist/internal/cursor-slack-relay.js +1 -0
  91. package/dist/internal/deploy-client.d.ts +2 -0
  92. package/dist/internal/deploy-client.d.ts.map +1 -1
  93. package/dist/internal/deploy-client.js +1 -0
  94. package/dist/internal/discovery.js +8 -1
  95. package/dist/internal/server.js +10 -5
  96. package/dist/playground/assets/{index-BDCeW4uc.js → index-CkN74U_L.js} +48 -47
  97. package/dist/playground/index.html +1 -1
  98. package/docs/README.md +1 -0
  99. package/docs/guides/slack.md +32 -7
  100. package/docs/guides/webhooks.md +183 -0
  101. package/docs/reference/channels.md +2 -1
  102. package/package.json +1 -1
  103. package/skills/setup-slack/SKILL.md +3 -0
  104. package/src/bin/agent-serve.ts +12 -3
  105. package/src/channels/slack/auth.ts +29 -22
  106. package/src/channels/slack/cursor-account.ts +2 -0
  107. package/src/channels/slack/defaults.ts +24 -4
  108. package/src/channels/slack/dispatch.ts +42 -1
  109. package/src/channels/slack/external-policy.ts +62 -0
  110. package/src/channels/slack/index.ts +1 -0
  111. package/src/channels/slack/interactive.ts +42 -10
  112. package/src/channels/slack/slack-channel.ts +20 -3
  113. package/src/channels/slack/types.ts +12 -0
  114. package/src/internal/cli-ax.ts +110 -34
  115. package/src/internal/cursor-slack-relay.ts +3 -0
  116. package/src/internal/deploy-client.ts +3 -0
  117. package/src/internal/discovery.ts +8 -1
  118. package/src/internal/server.ts +9 -5
  119. package/dist/docs/assets/chunks/@localSearchIndexroot.CQJytDwU.js +0 -1
  120. package/dist/docs/assets/guides_slack.md.D_xFKlnF.lean.js +0 -1
  121. package/dist/docs/assets/guides_webhooks.md.B1EswtUu.js +0 -50
  122. /package/dist/docs/assets/{index.md.Dfv5ic9t.lean.js → index.md.CH_s5uZe.lean.js} +0 -0
@@ -8,7 +8,7 @@
8
8
  />
9
9
  <meta name="viewport" content="width=device-width, initial-scale=1" />
10
10
  <title>agent-serve playground</title>
11
- <script type="module" crossorigin src="./assets/index-BDCeW4uc.js"></script>
11
+ <script type="module" crossorigin src="./assets/index-CkN74U_L.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="./assets/index-Bul1WfrB.css">
13
13
  </head>
14
14
  <body>
package/docs/README.md CHANGED
@@ -56,6 +56,7 @@ Pick your entry point based on your goal.
56
56
  | Learning from working agents | [Example agents](./example-agents/index.md) |
57
57
  | Wiring an agent to Slack | [Slack guide](./guides/slack.md) |
58
58
  | Wiring an agent to GitHub webhooks | [GitHub guide](./guides/github.md) |
59
+ | Driving an agent from Linear (or another tracker) | [Webhooks guide: Linear example](./guides/webhooks.md#example-linear-as-the-control-plane) |
59
60
  | Making an existing agent measurably better | [Evals](./evals.md), then [Hillclimbing](./hillclimbing.md) |
60
61
  | Comparing variants on live traffic | [Live A/B metrics](./ab.md) |
61
62
  | Deploying with Cursor or on your own infrastructure | [Deployment](./deployment.md) |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Slack"
3
- description: "Put the agent in Slack over Socket Mode: manifests, tokens, doctor, engagement rules, and approval buttons."
3
+ description: "Put the agent in Slack over Socket Mode: manifests, tokens, doctor, engagement rules, access policy, and approval buttons."
4
4
  ---
5
5
 
6
6
  # Slack agents
@@ -31,12 +31,13 @@ export default slackChannel({ envPrefix: "WEATHER_AGENT" });
31
31
 
32
32
  By default the channel connects over Socket Mode (the app connects
33
33
  outward, so it works from behind any firewall) and responds only to
34
- `app_mention` events and direct messages. Threads bind to sessions with
35
- `continuationToken = channelId:threadTs`, so a thread is one durable
36
- conversation. Replies stream (`chat.startStream` / `appendStream` /
37
- `stopStream`) with a plain-message fallback, a rotating status shows
38
- while the agent works, and the pack sets thread titles and suggested
39
- prompts. When a Slack API is unavailable (`method_not_found`,
34
+ `app_mention` events and direct messages. It also refuses Slack Connect
35
+ users, guests, and senders from other workspaces. Threads bind to
36
+ sessions with `continuationToken = channelId:threadTs`, so a thread is
37
+ one durable conversation. Replies stream (`chat.startStream` /
38
+ `appendStream` / `stopStream`) with a plain-message fallback, a rotating
39
+ status shows while the agent works, and the pack sets thread titles and
40
+ suggested prompts. When a Slack API is unavailable (`method_not_found`,
40
41
  `missing_scope`), the pack degrades to `postMessage` instead of failing.
41
42
 
42
43
  Missing tokens leave the channel idle (`channel idle … missing
@@ -71,6 +72,30 @@ claimed-thread reply). Channel watching and `toolApprovals` /
71
72
  Block Kit clicks. Agent names must be unique on the host; an unmatched
72
73
  `@Cursor <name>` stays on Cursor's normal Slack agent.
73
74
 
75
+ ## Control who can message the agent
76
+
77
+ External senders are blocked by default. Slack Connect users, guests, and
78
+ people whose home workspace is not the install team never reach the
79
+ handler. That applies to Socket Mode and `cursorAccount: true`. Set
80
+ `blockExternals: false` only when the agent should serve people outside
81
+ your org:
82
+
83
+ ```ts
84
+ export default slackChannel({
85
+ policy: { blockExternals: false },
86
+ });
87
+ ```
88
+
89
+ Sessions are thread-scoped by default, so anyone in the thread can
90
+ continue. Restrict follow-ups to the person who started the session with
91
+ `respondTo: "author"`:
92
+
93
+ ```ts
94
+ export default slackChannel({
95
+ policy: { respondTo: "author" },
96
+ });
97
+ ```
98
+
74
99
  ## Set it up
75
100
 
76
101
  ### Generate the app assets
@@ -177,6 +177,189 @@ swaps the default for `bearerAuth` on every channel that doesn't author
177
177
  its own chain, and `--allow-anonymous` swaps it for `allowAll()`
178
178
  (trusted networks only). Authored `auth` arrays always win over both.
179
179
 
180
+ ## Example: Linear as the control plane
181
+
182
+ Everything above composes into a working ticket-driven agent. This
183
+ example wires Linear to the agent: new issues and comments start or
184
+ resume sessions, and replies land back on the issue as comments. The
185
+ same shape works for any tracker with signed webhooks.
186
+
187
+ Create the webhook in Linear under Settings → API → "New webhook",
188
+ pointed at `https://<your-host>/<slug>/v1/channels/linear`, and copy
189
+ the signing secret. Linear requires a public HTTPS URL, so use a
190
+ tunnel during local development or test with signed fixtures (below).
191
+ Set three environment variables:
192
+
193
+ ```bash
194
+ LINEAR_WEBHOOK_SECRET=lin_wh_... # the webhook's signing secret
195
+ LINEAR_API_KEY=lin_api_... # posts replies as comments
196
+ LINEAR_AGENT_USER_ID=... # the API key's user: query { viewer { id } }
197
+ ```
198
+
199
+ `LINEAR_AGENT_USER_ID` matters: replies posted with the API key
200
+ trigger the Comment webhook again, so the channel must recognize and
201
+ skip its own comments. Without the guard, every reply starts another
202
+ turn.
203
+
204
+ Author `agent/channels/linear.ts`:
205
+
206
+ ```ts
207
+ import { Buffer } from "node:buffer";
208
+ import { createHmac, timingSafeEqual } from "node:crypto";
209
+ import { allowAll, defineChannel, POST } from "@cursor/july/channels";
210
+ import { z } from "zod";
211
+
212
+ const secret = process.env.LINEAR_WEBHOOK_SECRET ?? "";
213
+ const apiKey = process.env.LINEAR_API_KEY ?? "";
214
+ const agentUserId = process.env.LINEAR_AGENT_USER_ID ?? "";
215
+
216
+ interface LinearWebhook {
217
+ action: string;
218
+ type: string;
219
+ url?: string;
220
+ webhookTimestamp: number;
221
+ data: {
222
+ id: string;
223
+ title?: string;
224
+ description?: string;
225
+ body?: string;
226
+ issueId?: string;
227
+ userId?: string;
228
+ };
229
+ }
230
+
231
+ // Linear signs the raw body: hex HMAC-SHA256 in `Linear-Signature`.
232
+ function verified(rawBody: string, header: string | null): boolean {
233
+ if (secret === "" || header === null) {
234
+ return false;
235
+ }
236
+ const expected = createHmac("sha256", secret).update(rawBody).digest();
237
+ const received = Buffer.from(header, "hex");
238
+ return (
239
+ received.length === expected.length && timingSafeEqual(received, expected)
240
+ );
241
+ }
242
+
243
+ async function postComment(issueId: string, body: string): Promise<void> {
244
+ const response = await fetch("https://api.linear.app/graphql", {
245
+ method: "POST",
246
+ headers: { "content-type": "application/json", authorization: apiKey },
247
+ body: JSON.stringify({
248
+ query:
249
+ "mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }",
250
+ variables: { input: { issueId, body } },
251
+ }),
252
+ });
253
+ if (!response.ok) {
254
+ throw new Error(`commentCreate failed: ${response.status}`);
255
+ }
256
+ }
257
+
258
+ function issueIdFromToken(token: string | null): string | null {
259
+ if (token === null || !token.startsWith("linear:")) {
260
+ return null;
261
+ }
262
+ return token.slice("linear:".length);
263
+ }
264
+
265
+ export default defineChannel({
266
+ // The HMAC check is the request auth, so admit everything at the route.
267
+ auth: [allowAll()],
268
+ routes: [
269
+ POST("/", {
270
+ description: "Linear webhook ingress",
271
+ // The payload shape varies by `Linear-Event`; parse after verifying.
272
+ bodySchema: z.unknown(),
273
+ handler: async (request, { send, waitUntil }) => {
274
+ const rawBody = await request.text();
275
+ if (!verified(rawBody, request.headers.get("linear-signature"))) {
276
+ return Response.json({ ok: false }, { status: 401 });
277
+ }
278
+ const event = JSON.parse(rawBody) as LinearWebhook;
279
+ // Reject stale deliveries to guard against replay.
280
+ if (Math.abs(Date.now() - event.webhookTimestamp) > 60_000) {
281
+ return Response.json({ ok: false }, { status: 401 });
282
+ }
283
+ // Skip the agent's own comments so replies don't re-trigger it.
284
+ if (event.type === "Comment" && event.data.userId === agentUserId) {
285
+ return Response.json({ ok: true });
286
+ }
287
+
288
+ // New issue → new session. New comment → follow-up on the same
289
+ // session, keyed by issue id through the continuation token.
290
+ let issueId: string | undefined;
291
+ let message: string | undefined;
292
+ if (event.type === "Issue" && event.action === "create") {
293
+ issueId = event.data.id;
294
+ message = `New Linear issue: ${event.data.title}\n\n${
295
+ event.data.description ?? ""
296
+ }\n${event.url ?? ""}`;
297
+ } else if (event.type === "Comment" && event.action === "create") {
298
+ issueId = event.data.issueId;
299
+ message = event.data.body;
300
+ }
301
+ if (issueId === undefined || message === undefined) {
302
+ return Response.json({ ok: true });
303
+ }
304
+
305
+ // Linear retries on any non-200 and times out after five
306
+ // seconds: ACK now, run the turn in the background.
307
+ waitUntil(send(message, { continuationToken: `linear:${issueId}` }));
308
+ return Response.json({ ok: true });
309
+ },
310
+ }),
311
+ ],
312
+ events: {
313
+ async "message.completed"(event, channel) {
314
+ if (event.data.finishReason === "tool_call" || event.data.text === "") {
315
+ return;
316
+ }
317
+ const issueId = issueIdFromToken(channel.continuationToken);
318
+ if (issueId !== null) {
319
+ await postComment(issueId, event.data.text);
320
+ }
321
+ },
322
+ async "turn.failed"(event, channel) {
323
+ const issueId = issueIdFromToken(channel.continuationToken);
324
+ if (issueId !== null) {
325
+ await postComment(issueId, `Turn failed: ${event.data.message}`);
326
+ }
327
+ },
328
+ },
329
+ });
330
+ ```
331
+
332
+ The channel handles ingress and reply delivery deterministically. To
333
+ let the model read and update Linear during the turn (search related
334
+ issues, change state, assign), add the Linear MCP connection alongside
335
+ it:
336
+
337
+ ```ts
338
+ // agent/mcp-connections/linear.ts
339
+ import { defineConnection } from "@cursor/july/connections";
340
+
341
+ // Uses the signed-in Cursor account's Linear connector.
342
+ export default defineConnection({ cursorAccount: true, servers: ["Linear"] });
343
+ ```
344
+
345
+ See [MCP connections](../reference/connections.md) for the direct
346
+ `https://mcp.linear.app/mcp` form when the host isn't signed in to
347
+ Cursor.
348
+
349
+ To test without a public URL, save a payload from the webhook's
350
+ delivery log (or the sample in
351
+ [Linear's webhook docs](https://linear.app/developers/webhooks)) under
352
+ `fixtures/`, refresh its `webhookTimestamp`, and sign it yourself:
353
+
354
+ ```bash
355
+ SIG=$(node -e 'const {createHmac}=require("node:crypto");const fs=require("node:fs");
356
+ process.stdout.write(createHmac("sha256",process.env.LINEAR_WEBHOOK_SECRET)
357
+ .update(fs.readFileSync("fixtures/issue-create.json")).digest("hex"))')
358
+ curl -X POST http://127.0.0.1:3000/<slug>/v1/channels/linear/ \
359
+ -H 'content-type: application/json' -H "linear-signature: $SIG" \
360
+ --data-binary @fixtures/issue-create.json
361
+ ```
362
+
180
363
  ## Test a channel
181
364
 
182
365
  Start with curl and saved payloads under `fixtures/`. The playground's
@@ -174,7 +174,8 @@ declares `[localDevStrict()]` stays loopback-only even on an
174
174
  ## First class channels
175
175
 
176
176
  **Slack** (`@cursor/july/channels/slack`): Socket Mode
177
- transport, streaming replies, engagement rules, approval cards. Author
177
+ transport, streaming replies, engagement rules, approval cards, and a
178
+ default block on Slack Connect / guest / other-workspace senders. Author
178
179
  `agent/channels/slack.ts` with `slackChannel()`. Guide:
179
180
  [Slack](../guides/slack.md).
180
181
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cursor/july",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "(early alpha) Filesystem-first framework for defining Cursor agents as markdown and TypeScript and serving them over channels with the Cursor SDK.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "repository": {
@@ -97,6 +97,9 @@ export default slackChannel({
97
97
  single token — string literals with spaces fail typecheck; runtime
98
98
  values throw at construction/serve. Names must be unique on the host;
99
99
  an unmatched `@Cursor <name>` stays with Cursor's normal Slack agent.
100
+ External senders (Slack Connect / guests / other workspaces) are
101
+ blocked by default; see `policy.blockExternals` in
102
+ [docs/guides/slack.md](../../docs/guides/slack.md).
100
103
 
101
104
  ### A2 — Sign in + serve (agent)
102
105
 
@@ -85,7 +85,7 @@ Usage:
85
85
  ${CLI} sessions [--url <baseUrl> | --prod] [--json]
86
86
  [--slug <slug>] [--team <id>] [--dir <path>]
87
87
  ${CLI} session <sessionId> [--url <baseUrl> | --prod]
88
- [--json | --text | --events]
88
+ [--json | --text | --events] [--out <file.ndjson>]
89
89
  [--slug <slug>] [--team <id>] [--dir <path>]
90
90
  ${CLI} cost [sessionId] [--url <baseUrl> | --prod] [--json]
91
91
  [--slug <slug>] [--team <id>] [--dir <path>]
@@ -110,11 +110,12 @@ Usage:
110
110
  [--tag <tag>]... [--verbose] [--timeout-ms <n>]
111
111
  [--strict] [--max-concurrency <n>]
112
112
  [--junit <path>] [--artifacts <dir>|--no-artifacts]
113
+ [--out <file.json>]
113
114
  [--skip-report] [--no-stream] [--no-wait]
114
115
  [--prod | --url <baseUrl>] [--slug <slug>]
115
116
  [--team <id>]
116
117
  ${CLI} eval status [runId] --prod|--url <baseUrl> [--dir <path>] [--json]
117
- [--slug <slug>] [--team <id>]
118
+ [--out <file.json>] [--slug <slug>] [--team <id>]
118
119
  ${CLI} eval cancel <runId> --prod|--url <baseUrl> [--dir <path>] [--json]
119
120
  [--slug <slug>] [--team <id>]
120
121
  ${CLI} info [--dir <path>] [--json]
@@ -392,7 +393,11 @@ Options:
392
393
  --comment github replay: comment body for comment events.
393
394
  --context github replay: commit-status context.
394
395
  --dry-run github replay: print synthesized payloads, don't POST.
395
- --out github replay: write synthesized payloads as fixtures.
396
+ --out eval / eval status <runId>: also write the full results
397
+ JSON to this path. session: write the raw NDJSON trace
398
+ there instead of printing (same format as run --events;
399
+ playground-openable). github replay: write synthesized
400
+ payloads as fixtures.
396
401
  `;
397
402
 
398
403
  async function main(argv: string[]): Promise<number> {
@@ -775,6 +780,7 @@ async function main(argv: string[]): Promise<number> {
775
780
  apiKey: values["api-key"],
776
781
  slug: values.slug,
777
782
  bearerToken: values["bearer-token"],
783
+ out: values.out,
778
784
  })
779
785
  );
780
786
  }
@@ -819,6 +825,7 @@ async function main(argv: string[]): Promise<number> {
819
825
  artifacts: values["no-artifacts"] ? false : values.artifacts,
820
826
  skipReport: values["skip-report"],
821
827
  maxConcurrency: parseMaxConcurrency(values["max-concurrency"]),
828
+ out: values.out,
822
829
  })
823
830
  );
824
831
  }
@@ -912,6 +919,7 @@ async function main(argv: string[]): Promise<number> {
912
919
  json: values.json,
913
920
  text: values.text,
914
921
  rawEvents: values["raw-events"],
922
+ out: values.out,
915
923
  })
916
924
  );
917
925
  }
@@ -1291,6 +1299,7 @@ function axFromOptions(
1291
1299
  artifacts: partial.artifacts,
1292
1300
  skipReport: partial.skipReport,
1293
1301
  maxConcurrency: partial.maxConcurrency,
1302
+ out: partial.out,
1294
1303
  };
1295
1304
  }
1296
1305
 
@@ -4,29 +4,39 @@
4
4
 
5
5
  import { samePrincipal } from "../../internal/auth.js";
6
6
  import type { AuthContext } from "../../types.js";
7
- import type { SlackBinding, SlackBlockAction, SlackMessage } from "./types.js";
7
+ import type {
8
+ SlackBinding,
9
+ SlackBlockAction,
10
+ SlackMessage,
11
+ SlackRespondTo,
12
+ } from "./types.js";
8
13
 
9
- /**
10
- * Build a thread-scoped principal so any participant in the same Slack
11
- * thread can continue the session. The triggering user is preserved in
12
- * attributes for message context.
13
- */
14
- export function buildSlackAuthContext(args: {
14
+ export type BuildSlackAuthArgs = {
15
15
  channelId: string;
16
16
  threadTs: string;
17
17
  teamId?: string;
18
18
  userId?: string;
19
19
  userName?: string;
20
20
  isBot?: boolean;
21
- /**
22
- * Engagement surface that started the session (e.g. `"channel_post"` for
23
- * the opt-in channel watch). Recorded as an attribute; the principal stays
24
- * thread-scoped so an @mention in the same thread continues the session.
25
- */
26
21
  engagement?: string;
27
- }): AuthContext {
22
+ /** Defaults to `"thread"`. `"author"` appends `:{userId}` to the principal. */
23
+ respondTo?: SlackRespondTo;
24
+ };
25
+
26
+ /**
27
+ * Build a Slack session principal.
28
+ *
29
+ * - `"thread"` (default): any participant can continue.
30
+ * - `"author"`: only the starter (`…:{userId}`), same shape approvals already honor.
31
+ */
32
+ export function buildSlackAuthContext(args: BuildSlackAuthArgs): AuthContext {
28
33
  const team = args.teamId ?? "unknown";
29
- const principalId = `slack:${team}:${args.channelId}:${args.threadTs}`;
34
+ const respondTo = args.respondTo ?? "thread";
35
+ const base = `slack:${team}:${args.channelId}:${args.threadTs}`;
36
+ const authorScoped =
37
+ respondTo === "author" && args.userId !== undefined && args.userId !== "";
38
+ const principalId = authorScoped ? `${base}:${args.userId}` : base;
39
+
30
40
  const attributes: Record<string, string> = {
31
41
  channel_id: args.channelId,
32
42
  thread_ts: args.threadTs,
@@ -106,7 +116,7 @@ export function blockActionAuth(action: SlackBlockAction): AuthContext {
106
116
  export function defaultSlackAuth(
107
117
  message: SlackMessage,
108
118
  ctx: SlackBinding,
109
- options?: { engagement?: string }
119
+ options?: { engagement?: string; respondTo?: SlackRespondTo }
110
120
  ): AuthContext | null {
111
121
  if (message.author === undefined) {
112
122
  return null;
@@ -114,14 +124,11 @@ export function defaultSlackAuth(
114
124
  return buildSlackAuthContext({
115
125
  channelId: ctx.slack.channelId,
116
126
  threadTs: ctx.slack.threadTs,
117
- ...(message.teamId === undefined ? {} : { teamId: message.teamId }),
127
+ teamId: message.teamId,
118
128
  userId: message.author.userId,
119
- ...(message.author.userName === undefined
120
- ? {}
121
- : { userName: message.author.userName }),
129
+ userName: message.author.userName,
122
130
  isBot: message.author.isBot,
123
- ...(options?.engagement === undefined
124
- ? {}
125
- : { engagement: options.engagement }),
131
+ engagement: options?.engagement,
132
+ respondTo: options?.respondTo,
126
133
  });
127
134
  }
@@ -172,6 +172,8 @@ export async function dispatchCursorSlackEnvelope(args: {
172
172
  log,
173
173
  evalCommandsEnabled: runtime.options.evalCommands !== false,
174
174
  cursorAgentName: envelope.agentName,
175
+ // Backend router already refused externals for this envelope.
176
+ blockExternals: false,
175
177
  ...(args.hasContinuationSession === undefined
176
178
  ? {}
177
179
  : { hasContinuationSession: args.hasContinuationSession }),
@@ -20,9 +20,11 @@ import { createStreamDeliveryStrategy } from "./live-delivery.js";
20
20
  import { defaultSlackLog, type SlackLog } from "./log.js";
21
21
  import { buildTaskChunkForResult, buildTaskChunksForCalls } from "./stream.js";
22
22
  import type {
23
+ SlackAccessPolicy,
23
24
  SlackChannelState,
24
25
  SlackCredentials,
25
26
  SlackMessageHandler,
27
+ SlackRespondTo,
26
28
  SlackSuggestedPrompt,
27
29
  } from "./types.js";
28
30
 
@@ -30,6 +32,19 @@ const SLACK_TURN_FAILURE_FALLBACK =
30
32
  "I hit an error while handling your request. Please try again or rephrase.";
31
33
  const SLACK_TURN_FAILURE_MAX_CHARS = 400;
32
34
 
35
+ export function resolveSlackRespondTo(
36
+ policy: SlackAccessPolicy | undefined
37
+ ): SlackRespondTo {
38
+ return policy?.respondTo === "author" ? "author" : "thread";
39
+ }
40
+
41
+ /** Defaults to true when omitted. */
42
+ export function resolveSlackBlockExternals(
43
+ policy: SlackAccessPolicy | undefined
44
+ ): boolean {
45
+ return policy?.blockExternals !== false;
46
+ }
47
+
33
48
  /** Scrubbed turn.failed body for Slack (secrets / paths / URLs stripped). */
34
49
  export function formatSlackTurnFailureMessage(raw: string | undefined): string {
35
50
  const scrubbed = scrubSlackErrorDetail(raw?.trim() ?? "");
@@ -68,29 +83,34 @@ function resolveLoadingMessages(
68
83
 
69
84
  /** Default mention/DM inbound handler (optional custom loading rotation). */
70
85
  export function createDefaultOnInbound(
71
- loadingMessages?: readonly string[]
86
+ loadingMessages?: readonly string[],
87
+ policy?: SlackAccessPolicy
72
88
  ): SlackMessageHandler {
73
89
  const rotating = resolveLoadingMessages(loadingMessages);
90
+ const respondTo = resolveSlackRespondTo(policy);
74
91
  return async (ctx, message) => {
75
92
  await ctx.thread.startTyping("Thinking…", rotating);
76
- const auth = defaultSlackAuth(message, ctx);
93
+ const auth = defaultSlackAuth(message, ctx, { respondTo });
77
94
  return auth === null ? null : { auth };
78
95
  };
79
96
  }
80
97
 
81
98
  /**
82
99
  * Default watched-channel-post handler (optional custom loading rotation).
83
- * Same thread-scoped principal as mentions, tagged with
100
+ * Same principal policy as mentions, tagged with
84
101
  * `engagement: "channel_post"` for auth policies.
85
102
  */
86
103
  export function createDefaultOnChannelPost(
87
- loadingMessages?: readonly string[]
104
+ loadingMessages?: readonly string[],
105
+ policy?: SlackAccessPolicy
88
106
  ): SlackMessageHandler {
89
107
  const rotating = resolveLoadingMessages(loadingMessages);
108
+ const respondTo = resolveSlackRespondTo(policy);
90
109
  return async (ctx, message) => {
91
110
  await ctx.thread.startTyping("Thinking…", rotating);
92
111
  const auth = defaultSlackAuth(message, ctx, {
93
112
  engagement: "channel_post",
113
+ respondTo,
94
114
  });
95
115
  return auth === null ? null : { auth };
96
116
  };
@@ -17,6 +17,7 @@ import {
17
17
  parseSlackEvalDirective,
18
18
  } from "./eval-directive.js";
19
19
  import type { EventIdDedupe } from "./event-id-dedupe.js";
20
+ import { isExternalSlackSender } from "./external-policy.js";
20
21
  import {
21
22
  formatSlackContextBlock,
22
23
  parseAppMentionEvent,
@@ -91,6 +92,11 @@ export interface SlackInboundDispatchDeps extends SlackContinuationProbes {
91
92
  evalCommandsEnabled?: boolean;
92
93
  /** cursorAccount agent name stripped before `eval` directive parsing. */
93
94
  cursorAgentName?: string;
95
+ /**
96
+ * Drop external senders before the handler. Defaults to `true`; pass
97
+ * `false` to admit them (or when an upstream gate already refused them).
98
+ */
99
+ blockExternals?: boolean;
94
100
  }
95
101
 
96
102
  function triggeringMergeMessage(
@@ -132,6 +138,7 @@ export async function dispatchInboundMessage(args: {
132
138
  * (`OncallBuddy eval list`).
133
139
  */
134
140
  cursorAgentName?: string;
141
+ blockExternals?: boolean;
135
142
  }): Promise<void> {
136
143
  const { message, kind } = args;
137
144
  const log = args.log ?? defaultSlackLog;
@@ -142,7 +149,7 @@ export async function dispatchInboundMessage(args: {
142
149
  host: args.host,
143
150
  artifacts: args.artifacts,
144
151
  log,
145
- ...(message.teamId === undefined ? {} : { teamId: message.teamId }),
152
+ teamId: message.teamId,
146
153
  });
147
154
 
148
155
  log.info("inbound", {
@@ -155,6 +162,38 @@ export async function dispatchInboundMessage(args: {
155
162
  team: message.teamId,
156
163
  });
157
164
 
165
+ if (args.blockExternals !== false && message.author?.isBot !== true) {
166
+ const userId = message.author?.userId ?? "";
167
+ const teamId = message.teamId;
168
+ if (teamId === undefined || teamId === "") {
169
+ log.info("inbound dropped (missing team)", {
170
+ kind,
171
+ channel: message.channelId,
172
+ thread_ts: message.threadTs,
173
+ user: userId,
174
+ event_id: args.eventId,
175
+ });
176
+ return;
177
+ }
178
+ if (
179
+ await isExternalSlackSender({
180
+ botToken: args.credentials?.botToken,
181
+ slackTeamId: teamId,
182
+ userId,
183
+ })
184
+ ) {
185
+ log.info("inbound dropped (external sender)", {
186
+ kind,
187
+ channel: message.channelId,
188
+ thread_ts: message.threadTs,
189
+ user: userId,
190
+ team: teamId,
191
+ event_id: args.eventId,
192
+ });
193
+ return;
194
+ }
195
+ }
196
+
158
197
  const continuation = slackContinuationKey(
159
198
  message.channelId,
160
199
  message.threadTs
@@ -463,6 +502,7 @@ export function dispatchInboundFromEventBody(
463
502
  log,
464
503
  evalCommandsEnabled: deps.evalCommandsEnabled,
465
504
  cursorAgentName: deps.cursorAgentName,
505
+ blockExternals: deps.blockExternals,
466
506
  ...(deps.hasContinuationSession === undefined
467
507
  ? {}
468
508
  : { hasContinuationSession: deps.hasContinuationSession }),
@@ -504,6 +544,7 @@ export function dispatchInboundFromEventBody(
504
544
  log,
505
545
  evalCommandsEnabled: deps.evalCommandsEnabled,
506
546
  cursorAgentName: deps.cursorAgentName,
547
+ blockExternals: deps.blockExternals,
507
548
  ...(deps.hasContinuationSession === undefined
508
549
  ? {}
509
550
  : { hasContinuationSession: deps.hasContinuationSession }),
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Workspace-membership checks for AgentKit Slack agents.
3
+ * Mirrors backend `isExternalSlackUser` (cloud-agent/slack/externalChannelPolicy).
4
+ */
5
+
6
+ import { callSlackApi } from "./api.js";
7
+ import type { SlackBotToken } from "./types.js";
8
+
9
+ type SlackUserInfo = {
10
+ team_id?: string;
11
+ is_stranger?: boolean;
12
+ is_restricted?: boolean;
13
+ is_ultra_restricted?: boolean;
14
+ enterprise_user?: { teams?: string[] };
15
+ };
16
+
17
+ /** Pure classifier over a `users.info` user object. Fails open to external. */
18
+ export function classifySlackUserExternality(args: {
19
+ slackTeamId: string;
20
+ user: SlackUserInfo;
21
+ }): boolean {
22
+ const { slackTeamId, user } = args;
23
+ if (
24
+ user.is_stranger === true ||
25
+ user.is_restricted === true ||
26
+ user.is_ultra_restricted === true
27
+ ) {
28
+ return true;
29
+ }
30
+ return (
31
+ user.team_id !== undefined &&
32
+ user.team_id !== slackTeamId &&
33
+ !(user.enterprise_user?.teams ?? []).includes(slackTeamId)
34
+ );
35
+ }
36
+
37
+ /** Lookup + classify. Empty userId / API failure → external. */
38
+ export async function isExternalSlackSender(args: {
39
+ botToken: SlackBotToken | undefined;
40
+ slackTeamId: string;
41
+ userId: string;
42
+ }): Promise<boolean> {
43
+ if (args.userId === "") {
44
+ return true;
45
+ }
46
+ try {
47
+ const result = await callSlackApi({
48
+ botToken: args.botToken,
49
+ operation: "users.info",
50
+ body: { user: args.userId },
51
+ });
52
+ if (result.ok !== true || result.user === undefined) {
53
+ return true;
54
+ }
55
+ return classifySlackUserExternality({
56
+ slackTeamId: args.slackTeamId,
57
+ user: result.user as SlackUserInfo,
58
+ });
59
+ } catch {
60
+ return true;
61
+ }
62
+ }