wood-fired-tasks 2.2.0 → 2.3.0

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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Slack mrkdwn escaping primitive.
3
+ *
4
+ * Slack's mrkdwn parser treats `<...>` as control sequences: `<!channel>` /
5
+ * `<!here>` broadcast pings, `<@U123>` user mentions, and `<https://x|label>`
6
+ * spoofable links. User-controlled text (task/project/comment fields) flowing
7
+ * into a `mrkdwn` (or even a `plain_text`) block must be escaped so it renders
8
+ * as literal characters instead of injecting pings or fake links.
9
+ *
10
+ * Per Slack's spec only three characters need escaping, and ORDER MATTERS:
11
+ * `&` first so the `&amp;` we emit for `<`/`>` isn't itself re-escaped, then
12
+ * `<` → `&lt;`, then `>` → `&gt;`. Mirrors `escapeHtml` in `src/web/html.ts`.
13
+ *
14
+ * null / undefined collapse to '' so optional fields render cleanly without
15
+ * forcing every call site to defend against missing data.
16
+ */
17
+ const AMP = /&/g;
18
+ const LT = /</g;
19
+ const GT = />/g;
20
+ export function escapeSlackMrkdwn(value) {
21
+ if (value === null || value === undefined)
22
+ return '';
23
+ const s = typeof value === 'string' ? value : String(value);
24
+ return s.replace(AMP, '&amp;').replace(LT, '&lt;').replace(GT, '&gt;');
25
+ }
26
+ //# sourceMappingURL=mrkdwn.js.map
@@ -1,3 +1,4 @@
1
+ import { escapeSlackMrkdwn } from './mrkdwn.js';
1
2
  // ---------------------------------------------------------------------------
2
3
  // Lookup maps — exported so project-formatter.ts can reuse them (Plan 02)
3
4
  // ---------------------------------------------------------------------------
@@ -54,12 +55,12 @@ export function formatTaskList(tasks) {
54
55
  for (const task of displayTasks) {
55
56
  const emoji = STATUS_EMOJI[task.status] ?? '❓';
56
57
  const priority = PRIORITY_INDICATOR[task.priority] ?? task.priority;
57
- const assignee = task.assignee ? `@${task.assignee}` : '_unassigned_';
58
+ const assignee = task.assignee ? `@${escapeSlackMrkdwn(task.assignee)}` : '_unassigned_';
58
59
  const section = {
59
60
  type: 'section',
60
61
  text: {
61
62
  type: 'mrkdwn',
62
- text: `${emoji} *#${task.id} ${task.title}*\n${priority} · ${assignee}`,
63
+ text: `${emoji} *#${task.id} ${escapeSlackMrkdwn(task.title)}*\n${priority} · ${assignee}`,
63
64
  },
64
65
  };
65
66
  blocks.push(section);
@@ -91,20 +92,20 @@ export function formatTaskDetail(task) {
91
92
  const title = rawTitle.length > 150 ? rawTitle.slice(0, 147) + '...' : rawTitle;
92
93
  const header = {
93
94
  type: 'header',
94
- text: { type: 'plain_text', text: title, emoji: true },
95
+ text: { type: 'plain_text', text: escapeSlackMrkdwn(title), emoji: true },
95
96
  };
96
97
  blocks.push(header);
97
98
  // Fields section — 2-column key/value pairs (max 10 items in Slack API)
98
99
  const fields = [
99
100
  { type: 'mrkdwn', text: `*Status*\n${STATUS_EMOJI[task.status] ?? '❓'} ${task.status}` },
100
101
  { type: 'mrkdwn', text: `*Priority*\n${PRIORITY_INDICATOR[task.priority] ?? task.priority}` },
101
- { type: 'mrkdwn', text: `*Assignee*\n${task.assignee ?? '_unassigned_'}` },
102
- { type: 'mrkdwn', text: `*Due Date*\n${task.due_date ?? '_none_'}` },
102
+ { type: 'mrkdwn', text: `*Assignee*\n${escapeSlackMrkdwn(task.assignee) || '_unassigned_'}` },
103
+ { type: 'mrkdwn', text: `*Due Date*\n${escapeSlackMrkdwn(task.due_date) || '_none_'}` },
103
104
  { type: 'mrkdwn', text: `*Project*\n#${task.project_id}` },
104
- { type: 'mrkdwn', text: `*Created by*\n${task.created_by}` },
105
+ { type: 'mrkdwn', text: `*Created by*\n${escapeSlackMrkdwn(task.created_by)}` },
105
106
  ];
106
107
  if (task.tags.length > 0) {
107
- fields.push({ type: 'mrkdwn', text: `*Tags*\n${task.tags.join(', ')}` });
108
+ fields.push({ type: 'mrkdwn', text: `*Tags*\n${escapeSlackMrkdwn(task.tags.join(', '))}` });
108
109
  }
109
110
  const fieldsSection = { type: 'section', fields };
110
111
  blocks.push(fieldsSection);
@@ -114,7 +115,7 @@ export function formatTaskDetail(task) {
114
115
  blocks.push(divider);
115
116
  const descSection = {
116
117
  type: 'section',
117
- text: { type: 'mrkdwn', text: task.description.slice(0, 3000) },
118
+ text: { type: 'mrkdwn', text: escapeSlackMrkdwn(task.description.slice(0, 3000)) },
118
119
  };
119
120
  blocks.push(descSection);
120
121
  }
@@ -134,12 +135,12 @@ export function formatTaskNotification(event, projectName) {
134
135
  const emoji = STATUS_EMOJI[task.status] ?? '❓';
135
136
  const actor = metadata.actor ?? 'system';
136
137
  const label = EVENT_LABELS[eventType] ?? eventType;
137
- const assignee = task.assignee ? `@${task.assignee}` : '_unassigned_';
138
+ const assignee = task.assignee ? `@${escapeSlackMrkdwn(task.assignee)}` : '_unassigned_';
138
139
  const priority = PRIORITY_INDICATOR[task.priority] ?? task.priority;
139
140
  const text = [
140
141
  `*${label}* by ${actor}`,
141
- `${emoji} *#${task.id} ${task.title}*`,
142
- ...(projectName ? [`_${projectName}_`] : []),
142
+ `${emoji} *#${task.id} ${escapeSlackMrkdwn(task.title)}*`,
143
+ ...(projectName ? [`_${escapeSlackMrkdwn(projectName)}_`] : []),
143
144
  `${priority} · ${assignee}`,
144
145
  `\`/tasks show ${task.id}\``,
145
146
  ].join('\n');
@@ -320,7 +320,14 @@ filters. MCP list tools wrap the same envelope under a domain key
320
320
 
321
321
  Global rate limit (`@fastify/rate-limit`) applies to every REST route except
322
322
  `/health*`; defaults are 1000 req/min, tunable via `RATE_LIMIT_MAX` and
323
- `RATE_LIMIT_TIME_WINDOW`. Response shape on breach:
323
+ `RATE_LIMIT_TIME_WINDOW`. Sensitive auth/device routes (`/auth/login`,
324
+ `/auth/callback`, `/auth/device/code`, `/auth/device/verify`,
325
+ `/auth/device/token`) carry tighter per-route budgets (`RATE_LIMIT_AUTH_MAX`,
326
+ default 10; `RATE_LIMIT_DEVICE_TOKEN_MAX`, default 30). Client identity is
327
+ keyed on the authenticated principal when present, else `request.ip`; behind a
328
+ reverse proxy set `TRUST_PROXY` (default off) so `request.ip` derives from
329
+ `X-Forwarded-For` — with it off, a spoofed forwarded header cannot change a
330
+ client's bucket. Response shape on breach:
324
331
  `{ error: 'TOO_MANY_REQUESTS', message }` with HTTP 429.
325
332
 
326
333
  ## Error handling cheatsheet
package/docs/SETUP.md CHANGED
@@ -391,46 +391,47 @@ visiting `/auth/login`; otherwise the session cookie is dropped and login
391
391
  silently loops back to the login page. In `development`/`test` the cookie
392
392
  is non-`secure`, so plain `http://localhost` works.
393
393
 
394
+ ### 3a. Device-flow verification origin & trust boundary
395
+
396
+ The RFC 8628 **device flow** returns a `verification_uri` derived **per-request**
397
+ from the address the client connected to (`Host` / `X-Forwarded-{Host,Proto}`;
398
+ see `resolveVerificationOrigin`) so a LAN/remote client gets a routable URL not
399
+ `localhost`. It is **not** host-header injection — the URI is returned only to
400
+ the requesting client, so a spoofed `Host` only misdirects the spoofer; the
401
+ server trusts its front-proxy to set `X-Forwarded-*` honestly (the same trust
402
+ `TRUST_PROXY` extends to rate-limit client identity). To pin the boundary, set
403
+ `DEVICE_FLOW_TRUSTED_HOSTS` (comma-separated hostname allowlist): a `Host` not on
404
+ it falls back to the `OIDC_REDIRECT_URI` origin; unset = all.
405
+
394
406
  ### 4. (Optional) `LEGACY_AUTH_SUNSET_DATE`
395
407
 
396
- [NOTE] The legacy `X-API-Key` auth path has been **removed** the server now
397
- rejects that header with 401, so the `Deprecation`/`Sunset` headers it used to
398
- stamp no longer apply. The `LEGACY_AUTH_SUNSET_DATE` env var is still parsed for
399
- backward compatibility but has no runtime effect; new deployments can omit it.
400
-
401
- ```bash
402
- # Still accepted (YYYY-MM-DD) but inert — retained for backward compatibility.
403
- LEGACY_AUTH_SUNSET_DATE=2026-12-31
404
- ```
408
+ [NOTE] The legacy `X-API-Key` auth path has been **removed** (rejected with 401),
409
+ so this var is now inert still parsed (`YYYY-MM-DD`) for backward
410
+ compatibility but with no runtime effect. New deployments can omit it.
405
411
 
406
412
  ### 5. Verify the OIDC flow
407
413
 
408
414
  1. Restart the server (`npm run dev` locally).
409
415
  2. In a browser, visit `http://localhost:3000/auth/login`.
410
- 3. You should be redirected to Google, complete consent, and land on
411
- `/me`. The `/me` page shows your email, display name, and a list of
412
- your PATs.
413
- 4. From `/me` you can mint a new PAT (the value is shown **once**, copy
414
- it then) or revoke an existing one.
416
+ 3. Complete Google consent and land on `/me` (shows your email, display
417
+ name, and PATs).
418
+ 4. From `/me` mint a new PAT (value shown **once**, copy it then) or revoke one.
415
419
 
416
420
  ### 6. Bootstrap a PAT without a browser (servers, CI, headless agents)
417
421
 
418
- For deployments where no browser is available, mint the first PAT
419
- directly against the SQLite database:
422
+ For deployments where no browser is available, mint the first PAT directly
423
+ against the SQLite database (`--user` = numeric id, email, or legacy
424
+ display_name; `--name` = required label):
420
425
 
421
426
  ```bash
422
- # Adds a row to api_tokens for the named user.
423
- # --user accepts a numeric id, email (case-insensitive), or legacy display_name.
424
- # --name is a required human-readable label for the token.
425
427
  node dist/cli/bin/tasks.js db mint-token --user you@example.com --name my-laptop
426
428
  ```
427
429
 
428
430
  The command prints the raw PAT to stdout once. Use it as the
429
- `Authorization: Bearer wft_pat_<…>` value on subsequent requests, or as
430
- the `WFT_API_KEY` env var in MCP and CLI clients (the REST client switches
431
- to `Authorization: Bearer …` automatically when the value starts with
432
- `wft_pat_`). See [`SECURITY.md`](../SECURITY.md) →
433
- **Authentication Architecture** for the full chain.
431
+ `Authorization: Bearer wft_pat_<…>` value on subsequent requests, or as the
432
+ `WFT_API_KEY` env var in MCP/CLI clients (the REST client auto-switches to
433
+ `Authorization: Bearer …` for `wft_pat_` values). See [`SECURITY.md`](../SECURITY.md)
434
+ **Authentication Architecture** for the full chain.
434
435
 
435
436
  [WARNING] **PATs have NO default expiry.** The `api_tokens.expires_at`
436
437
  column is nullable (migration `008-identity-tables.ts`) and is written
@@ -1379,16 +1380,27 @@ variable the server reads, plus the CLI- and MCP-specific variables.
1379
1380
  | `SSE_MAX_CONNECTIONS_PER_KEY` | no | `4` | Per-credential (PAT) cap on concurrent SSE connections. 429 with `Retry-After` when exceeded. |
1380
1381
  | `SSE_MAX_CONNECTIONS_PER_IP` | no | `8` | Per-IP cap on concurrent SSE connections. |
1381
1382
  | `SSE_MAX_CONNECTIONS` | no | `200` | Global cap on concurrent SSE connections. |
1383
+ | `DEVICE_FLOW_TRUSTED_HOSTS` | no | — (all hosts honored) | Optional comma-separated allowlist of hostnames the device-flow `verification_uri` may be built from (`host` or `host:port`; port ignored in match). When set, a request whose `Host`/`X-Forwarded-Host` is not on the list falls back to the configured origin. See [§3a](#3a-device-flow-verification-origin--trust-boundary). |
1382
1384
  | `SLACK_BOT_TOKEN` | conditional | — | Slack bot token (`xoxb-…`). Required if any Slack var is set; refused alone (see [`docs/SLACK.md`](SLACK.md)). |
1383
1385
  | `SLACK_APP_TOKEN` | conditional | — | Slack app-level token (`xapp-…`) for Socket Mode. Must be set together with `SLACK_BOT_TOKEN` or neither. |
1384
1386
  | `SLACK_SIGNING_SECRET` | conditional | — | Slack request signing secret. Required when running Slack in HTTP mode; harmless in Socket Mode. |
1385
1387
 
1386
- ### Rate limiting (read directly in `src/api/server.ts`)
1388
+ ### Rate limiting (validated in `src/config/env.ts`)
1389
+
1390
+ The global limiter keys on the authenticated principal (PAT token id, else user
1391
+ id), falling back to `request.ip`; the five sensitive auth/device routes get a
1392
+ tighter per-route budget. `TRUST_PROXY` (default off — see [§3a](#3a-device-flow-verification-origin--trust-boundary))
1393
+ makes `request.ip` resolve from `X-Forwarded-For`; off, a spoofed header cannot
1394
+ move a bucket; behind a proxy, enable it or all clients share one IP bucket.
1387
1395
 
1388
1396
  | Variable | Required | Default | Description |
1389
1397
  |----------|----------|---------|-------------|
1390
- | `RATE_LIMIT_MAX` | no | `1000` | Maximum requests per window (global, via `@fastify/rate-limit`). `/health` is allow-listed. |
1391
- | `RATE_LIMIT_TIME_WINDOW` | no | `1 minute` | Window string accepted by `@fastify/rate-limit`. |
1398
+ | `RATE_LIMIT_MAX` | no | `1000` | Global max requests/window. `/health` is allow-listed. |
1399
+ | `RATE_LIMIT_TIME_WINDOW` | no | `1 minute` | Global window string. |
1400
+ | `RATE_LIMIT_AUTH_MAX` | no | `10` | Per-route max for login, callback, device/code, device/verify. |
1401
+ | `RATE_LIMIT_AUTH_TIME_WINDOW` | no | `1 minute` | Window for the per-route auth limits (incl. device/token). |
1402
+ | `RATE_LIMIT_DEVICE_TOKEN_MAX` | no | `30` | Per-route max for `/auth/device/token` (CLI polls it — looser). |
1403
+ | `TRUST_PROXY` | no | `false` | `false`/unset = ignore forwarded headers; `true` = trust all hops; integer = trust N hops; `ip,cidr,…` = trust only those proxy IPs/CIDRs. |
1392
1404
 
1393
1405
  ### Model catalog (read directly in `src/index.ts`)
1394
1406
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wood-fired-tasks",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Network-wide task tracking system for Wood Fired Games",
5
5
  "keywords": [
6
6
  "task-tracker",
@@ -122,11 +122,11 @@
122
122
  "@fastify/cookie": "^11.0.2",
123
123
  "@fastify/formbody": "^8.0.2",
124
124
  "@fastify/helmet": "^13.0.2",
125
- "@fastify/rate-limit": "^10.3.0",
125
+ "@fastify/rate-limit": "^11.0.0",
126
126
  "@fastify/secure-session": "^8.3.0",
127
127
  "@fastify/sse": "^0.4.0",
128
128
  "@fastify/swagger": "^9.7.0",
129
- "@fastify/swagger-ui": "^5.2.6",
129
+ "@fastify/swagger-ui": "^6.0.0",
130
130
  "@modelcontextprotocol/sdk": "^1.29.0",
131
131
  "@slack/bolt": "^4.6.0",
132
132
  "better-sqlite3": "^12.6.2",
@@ -136,7 +136,7 @@
136
136
  "dotenv": "^17.3.1",
137
137
  "env-paths": "^4.0.0",
138
138
  "fastify": "^5.8.5",
139
- "fastify-plugin": "^5.1.0",
139
+ "fastify-plugin": "^6.0.0",
140
140
  "fastify-type-provider-zod": "^6.1.0",
141
141
  "openid-client": "^6.8.4",
142
142
  "pino": "^10.3.1",
@@ -150,7 +150,7 @@
150
150
  "qs": "^6.15.2"
151
151
  },
152
152
  "devDependencies": {
153
- "@biomejs/biome": "2.4.16",
153
+ "@biomejs/biome": "2.5.0",
154
154
  "@fast-check/vitest": "^0.4.1",
155
155
  "@slack/types": "^2.20.0",
156
156
  "@slack/web-api": "^7.15.2",
@@ -68,6 +68,8 @@ wake their targets. Dedup: `sweep:<rule>:<floor(now / idempotency_window)>`,
68
68
  so a restart within the same window dispatches nothing. See
69
69
  [docs/event-router-design.md §Cold-start sweep](../../docs/event-router-design.md).
70
70
 
71
+ **Periodic re-sweep** (opt-in, default off): `sweep_interval_s: <seconds>` (per rule or under `defaults:`) re-runs that sweep on a timer — no restart, no SSE event — re-kicking sessions that went idle mid-run; same per-window dedup.
72
+
71
73
  - **Recipes** (full walkthroughs): [docs/automation-recipes/](https://github.com/Wood-Fired-Games/wood-fired-tasks/tree/main/docs/automation-recipes)
72
74
  - [claude-routines.md](../../docs/automation-recipes/claude-routines.md) — dispatch a routine on task close
73
75
  - [persistent-agent-sessions.md](../../docs/automation-recipes/persistent-agent-sessions.md) — drive a long-lived agent session
@@ -181,6 +181,7 @@ export declare const RuleSchema: z.ZodObject<{
181
181
  max_dispatches_per_minute: z.ZodOptional<z.ZodNumber>;
182
182
  max_retries: z.ZodOptional<z.ZodNumber>;
183
183
  sweep_on_start: z.ZodOptional<z.ZodBoolean>;
184
+ sweep_interval_s: z.ZodOptional<z.ZodNumber>;
184
185
  }, z.core.$strict>;
185
186
  /** `defaults:` block — optional, all sub-keys optional with documented values. */
186
187
  export declare const DefaultsSchema: z.ZodObject<{
@@ -189,6 +190,7 @@ export declare const DefaultsSchema: z.ZodObject<{
189
190
  max_dispatches_per_minute: z.ZodOptional<z.ZodNumber>;
190
191
  max_retries: z.ZodOptional<z.ZodNumber>;
191
192
  sweep_on_start: z.ZodOptional<z.ZodBoolean>;
193
+ sweep_interval_s: z.ZodOptional<z.ZodNumber>;
192
194
  }, z.core.$strict>;
193
195
  /** Top-level triggers.yaml schema. */
194
196
  export declare const TriggersConfigSchema: z.ZodObject<{
@@ -199,6 +201,7 @@ export declare const TriggersConfigSchema: z.ZodObject<{
199
201
  max_dispatches_per_minute: z.ZodOptional<z.ZodNumber>;
200
202
  max_retries: z.ZodOptional<z.ZodNumber>;
201
203
  sweep_on_start: z.ZodOptional<z.ZodBoolean>;
204
+ sweep_interval_s: z.ZodOptional<z.ZodNumber>;
202
205
  }, z.core.$strict>>;
203
206
  rules: z.ZodArray<z.ZodObject<{
204
207
  name: z.ZodString;
@@ -280,6 +283,7 @@ export declare const TriggersConfigSchema: z.ZodObject<{
280
283
  max_dispatches_per_minute: z.ZodOptional<z.ZodNumber>;
281
284
  max_retries: z.ZodOptional<z.ZodNumber>;
282
285
  sweep_on_start: z.ZodOptional<z.ZodBoolean>;
286
+ sweep_interval_s: z.ZodOptional<z.ZodNumber>;
283
287
  }, z.core.$strict>>;
284
288
  }, z.core.$strict>;
285
289
  export type TriggersConfig = z.infer<typeof TriggersConfigSchema>;
@@ -33,6 +33,7 @@
33
33
  * any specific receiver. (See docs/event-router-design.md §"Vendor-neutral
34
34
  * guardrails".)
35
35
  */
36
+ import { lstatSync, realpathSync, statSync } from 'node:fs';
36
37
  import { readFile } from 'node:fs/promises';
37
38
  import { parse as parseYaml } from 'yaml';
38
39
  import { z } from 'zod';
@@ -101,6 +102,16 @@ export const RuleSchema = z
101
102
  * handler path. Default OFF: absent = no startup behavior change.
102
103
  */
103
104
  sweep_on_start: z.boolean().optional(),
105
+ /**
106
+ * Periodic re-sweep interval in seconds (task #1035). When set (here or
107
+ * in `defaults:`), the daemon re-runs the SAME sweep this rule's
108
+ * `sweep_on_start` path uses, every `sweep_interval_s` seconds, with NO
109
+ * router restart and NO new SSE event — closing the steady-state gap
110
+ * where a session goes idle while the router is healthy. Bucket
111
+ * idempotency (`sweep:<rule>:<floor(now / idempotency_window)>`) caps it
112
+ * at one kick per window. Default OFF: absent (or 0) = no timer.
113
+ */
114
+ sweep_interval_s: z.number().int().positive().optional(),
104
115
  })
105
116
  .strict();
106
117
  /** `defaults:` block — optional, all sub-keys optional with documented values. */
@@ -116,6 +127,8 @@ export const DefaultsSchema = z
116
127
  max_retries: z.number().int().nonnegative().optional(),
117
128
  /** Global cold-start sweep opt-in; per-rule `sweep_on_start` overrides. Default false. */
118
129
  sweep_on_start: z.boolean().optional(),
130
+ /** Global periodic re-sweep interval (s); per-rule `sweep_interval_s` overrides. Default off. */
131
+ sweep_interval_s: z.number().int().positive().optional(),
119
132
  })
120
133
  .strict();
121
134
  /** Top-level triggers.yaml schema. */
@@ -213,6 +226,58 @@ function checkStringForTemplatingViolations(s, path, issues) {
213
226
  'at bare JSON value positions only)',
214
227
  });
215
228
  }
229
+ /**
230
+ * Enforce the documented `triggers.yaml` trust posture at startup: the file
231
+ * that drives shell_exec / webhook_post / create_task / agent dispatch MUST be
232
+ * owner-only (mode `0600`) and owned by the router user, because edit access is
233
+ * equivalent to arbitrary code execution as the router. See
234
+ * docs/event-router-design.md §"`triggers.yaml` trust posture".
235
+ *
236
+ * Mirrors the stat/owner/symlink hardening already used by
237
+ * `handlers/agent-session-dispatch.ts:resolveAdapter`, applied to a SINGLE file:
238
+ * - The path is resolved through {@link realpathSync} and the REAL file is
239
+ * stat'd, so a symlink cannot point at a 0600 file while the link target is
240
+ * attacker-writable.
241
+ * - Mode is rejected if ANY group/other bit is set — `(mode & 0o077) !== 0`.
242
+ * This is the strict `0600` reading promised by the design doc, so
243
+ * 0640/0644/0660/0666 all fail while 0600 passes.
244
+ * - Owner is rejected (POSIX only) when `process.getuid()` is defined and the
245
+ * real file's `uid` differs. `getuid` is undefined on Windows, where mode
246
+ * and uid bits are not meaningful — there, enforcement is skipped and file
247
+ * permissions are a deployment requirement.
248
+ *
249
+ * Returns `null` when the file passes (or the platform is non-POSIX); otherwise
250
+ * a single ` - <file>: <message>` error string in the established result shape.
251
+ */
252
+ function checkTriggersFilePermissions(filePath) {
253
+ const routerUid = process.getuid?.();
254
+ // Non-POSIX (Windows): mode/uid bits are unreliable. Skip enforcement — file
255
+ // permissions are a documented deployment requirement on that platform.
256
+ if (routerUid === undefined) {
257
+ return null;
258
+ }
259
+ let st;
260
+ try {
261
+ // Resolve symlinks so the bytes actually read are the ones we vet. lstat
262
+ // first to surface a dangling-symlink/missing-target clearly.
263
+ lstatSync(filePath);
264
+ const realPath = realpathSync(filePath);
265
+ st = statSync(realPath);
266
+ }
267
+ catch (err) {
268
+ const message = err instanceof Error ? err.message : String(err);
269
+ return ` - ${filePath}: cannot stat for permission check: ${message}`;
270
+ }
271
+ if ((st.mode & 0o077) !== 0) {
272
+ return ` - ${filePath}: insecure permissions (mode ${(st.mode & 0o777)
273
+ .toString(8)
274
+ .padStart(4, '0')}); must be mode 0600 / not accessible to group or other`;
275
+ }
276
+ if (st.uid !== routerUid) {
277
+ return ` - ${filePath}: not owned by the router user (file uid ${st.uid}, router uid ${routerUid})`;
278
+ }
279
+ return null;
280
+ }
216
281
  /**
217
282
  * Read a `triggers.yaml` file from disk, parse it, run the zod schema, and
218
283
  * then run the templating-safety pass. On success returns the typed config;
@@ -229,6 +294,13 @@ export async function loadAndValidateTriggers(filePath) {
229
294
  const message = err instanceof Error ? err.message : String(err);
230
295
  return { ok: false, errors: [` - <file>: cannot read ${filePath}: ${message}`] };
231
296
  }
297
+ // Trust gate: the file just read must be owner-only and owned by the router
298
+ // user (POSIX). Reject before parsing so an attacker-writable config never
299
+ // reaches the handler dispatch surface. See checkTriggersFilePermissions.
300
+ const permError = checkTriggersFilePermissions(filePath);
301
+ if (permError !== null) {
302
+ return { ok: false, errors: [permError] };
303
+ }
232
304
  let parsed;
233
305
  try {
234
306
  parsed = parseYaml(raw);
@@ -74,6 +74,29 @@ export type HandlerRegistry = Record<TriggersRule['do'], Handler>;
74
74
  * this seam so tests can hand it a fake generator they fully control.
75
75
  */
76
76
  export type SSESourceFactory = (signal: AbortSignal) => AsyncGenerator<SSEEvent, ExitCode>;
77
+ /** Opaque handle returned by an {@link IntervalScheduler}'s `set`. */
78
+ export type IntervalHandle = unknown;
79
+ /**
80
+ * Minimal interval-scheduler seam (task #1035). The periodic re-sweep timer
81
+ * fires through this rather than calling `setInterval` directly, so tests can
82
+ * inject a fake that captures the callback and drive it deterministically
83
+ * alongside the injected `now` clock (the sweep's bucket identity is derived
84
+ * from `now`, so the two must advance together under test control). Production
85
+ * defaults to {@link DEFAULT_INTERVAL_SCHEDULER} (real `setInterval`, unref'd
86
+ * so the timer never independently holds the event loop open).
87
+ */
88
+ export interface IntervalScheduler {
89
+ /** Schedule `cb` to run every `ms`; return an opaque cancel handle. */
90
+ set(cb: () => void, ms: number): IntervalHandle;
91
+ /** Cancel a previously scheduled interval. */
92
+ clear(handle: IntervalHandle): void;
93
+ }
94
+ /**
95
+ * Production interval scheduler: real `setInterval`/`clearInterval`. The
96
+ * timer is `unref`'d so it never keeps the process alive on its own — the SSE
97
+ * consume loop owns process liveness; the periodic sweep is a passenger.
98
+ */
99
+ export declare const DEFAULT_INTERVAL_SCHEDULER: IntervalScheduler;
77
100
  /** Minimal structured-logger surface the daemon needs (pino-compatible). */
78
101
  export interface DaemonLogger extends HandlerLogger {
79
102
  info(obj: Record<string, unknown>, msg?: string): void;
@@ -118,6 +141,13 @@ export interface DaemonDeps {
118
141
  env?: NodeJS.ProcessEnv;
119
142
  /** Injected clock for debounce/rate-limit math in tests. Default: Date.now. */
120
143
  now?: () => number;
144
+ /**
145
+ * Injected interval scheduler for the periodic re-sweep timer (task #1035).
146
+ * Default: {@link DEFAULT_INTERVAL_SCHEDULER} (real, unref'd `setInterval`).
147
+ * Tests inject a fake that captures the tick callback so the periodic sweep
148
+ * can be driven deterministically together with the injected `now`.
149
+ */
150
+ intervalScheduler?: IntervalScheduler;
121
151
  /**
122
152
  * Optional Prometheus metrics registry. ADDITIVE (task #434): when present,
123
153
  * the daemon increments it at the pipeline points it already tracks (events
@@ -189,6 +219,8 @@ export declare class WftRouterDaemon {
189
219
  private readonly metrics?;
190
220
  /** Injected clock (shared with debounce/rate-limit; drives sweep buckets). */
191
221
  private readonly now;
222
+ /** Injected interval scheduler for the periodic re-sweep timer (task #1035). */
223
+ private readonly intervalScheduler;
192
224
  private phase;
193
225
  private readonly abortController;
194
226
  /** The background consume loop; awaited by `stop()` so drain is ordered. */
@@ -203,6 +235,8 @@ export declare class WftRouterDaemon {
203
235
  private exitCode;
204
236
  /** The one-shot cold-start sweep (task #1005); null when no rule opted in. */
205
237
  private sweepPromise;
238
+ /** Live periodic re-sweep timer handles (task #1035); cleared in `stop()`. */
239
+ private readonly intervalHandles;
206
240
  constructor(deps: DaemonDeps);
207
241
  /**
208
242
  * Begin consuming the SSE generator in a background loop. Returns
@@ -217,6 +251,14 @@ export declare class WftRouterDaemon {
217
251
  * can deterministically observe "sweep finished" without racing `stop()`.
218
252
  */
219
253
  waitForSweep(): Promise<void>;
254
+ /**
255
+ * Await every currently in-flight dispatch fan-out + sweep tick (task
256
+ * #1035). Exposed so tests driving the periodic timer can deterministically
257
+ * observe a tick's sweep settling without calling `stop()` (which would
258
+ * tear the timer down). Snapshots the set so work scheduled AFTER the call
259
+ * does not extend the wait.
260
+ */
261
+ settle(): Promise<void>;
220
262
  /**
221
263
  * Abort the SSE generator, then drain all in-flight dispatch fan-outs and
222
264
  * any pending debounced buckets. Idempotent — a second call returns the
@@ -258,6 +300,16 @@ export declare class WftRouterDaemon {
258
300
  * down the live SSE pipeline.
259
301
  */
260
302
  private runStartupSweep;
303
+ /**
304
+ * Periodic re-sweep (task #1035): schedule a timer that re-runs THIS rule's
305
+ * sweep every `intervalMs`. The tick runs `sweepRule` — the exact same
306
+ * query → predicate → at-most-one-dispatch path the cold-start sweep uses —
307
+ * so the deterministic `sweep:<rule>:<bucket>` identity + idempotency claim
308
+ * cap it at one kick per window (AC #1/#2). A tick error is isolated: it
309
+ * logs a WARN and the timer keeps firing (AC #4). Each tick's in-flight
310
+ * work is `track`ed so `stop()` drains it (AC #5).
311
+ */
312
+ private startPeriodicSweep;
261
313
  /** Sweep ONE rule: query, predicate-match, then dispatch at most once. */
262
314
  private sweepRule;
263
315
  /**
@@ -60,6 +60,23 @@ import { findFirstMatchingOpenTask, sweepEventId } from './dispatch/startup-swee
60
60
  import { agentSessionDispatch, createTaskInProject, shellExec, webhookPost, } from './handlers/index.js';
61
61
  import { ExitCode, isControlEvent } from './sse/index.js';
62
62
  import { omitUndefined } from './util/omit-undefined.js';
63
+ /**
64
+ * Production interval scheduler: real `setInterval`/`clearInterval`. The
65
+ * timer is `unref`'d so it never keeps the process alive on its own — the SSE
66
+ * consume loop owns process liveness; the periodic sweep is a passenger.
67
+ */
68
+ export const DEFAULT_INTERVAL_SCHEDULER = {
69
+ set(cb, ms) {
70
+ const handle = setInterval(cb, ms);
71
+ if (typeof handle.unref === 'function') {
72
+ handle.unref();
73
+ }
74
+ return handle;
75
+ },
76
+ clear(handle) {
77
+ clearInterval(handle);
78
+ },
79
+ };
63
80
  /**
64
81
  * The default production handler registry: the four real handlers keyed by
65
82
  * their `do:` name. Tests override this with recording fakes.
@@ -155,6 +172,8 @@ export class WftRouterDaemon {
155
172
  metrics;
156
173
  /** Injected clock (shared with debounce/rate-limit; drives sweep buckets). */
157
174
  now;
175
+ /** Injected interval scheduler for the periodic re-sweep timer (task #1035). */
176
+ intervalScheduler;
158
177
  phase = 'idle';
159
178
  abortController = new AbortController();
160
179
  /** The background consume loop; awaited by `stop()` so drain is ordered. */
@@ -169,6 +188,8 @@ export class WftRouterDaemon {
169
188
  exitCode = ExitCode.CleanShutdown;
170
189
  /** The one-shot cold-start sweep (task #1005); null when no rule opted in. */
171
190
  sweepPromise = null;
191
+ /** Live periodic re-sweep timer handles (task #1035); cleared in `stop()`. */
192
+ intervalHandles = [];
172
193
  constructor(deps) {
173
194
  this.config = deps.config;
174
195
  this.store = deps.store;
@@ -194,6 +215,7 @@ export class WftRouterDaemon {
194
215
  }
195
216
  const now = deps.now ?? Date.now;
196
217
  this.now = now;
218
+ this.intervalScheduler = deps.intervalScheduler ?? DEFAULT_INTERVAL_SCHEDULER;
197
219
  this.rateLimiter =
198
220
  deps.rateLimiter ??
199
221
  new RateLimiter({
@@ -236,6 +258,19 @@ export class WftRouterDaemon {
236
258
  this.sweepPromise = this.runStartupSweep(sweepRules);
237
259
  this.track(this.sweepPromise);
238
260
  }
261
+ // Periodic re-sweep (task #1035): OPT-IN timed re-run of the SAME sweep
262
+ // for rules with a positive `sweep_interval_s` (per-rule or via
263
+ // `defaults:`). Independent of `sweep_on_start` — a rule may opt into the
264
+ // periodic path alone. Each opted-in rule gets its OWN timer so one rule's
265
+ // sweep failure can never stall another's (per-rule error isolation,
266
+ // AC #4). Absent / non-positive interval = no timer (zero behavior change,
267
+ // AC #3). Cleared in `stop()`.
268
+ for (const rule of this.config.rules) {
269
+ const intervalS = rule.sweep_interval_s ?? this.config.defaults?.sweep_interval_s ?? 0;
270
+ if (intervalS > 0) {
271
+ this.startPeriodicSweep(rule, intervalS * 1000);
272
+ }
273
+ }
239
274
  }
240
275
  /**
241
276
  * Await the cold-start sweep's completion (resolves immediately when no
@@ -247,6 +282,16 @@ export class WftRouterDaemon {
247
282
  await this.sweepPromise;
248
283
  }
249
284
  }
285
+ /**
286
+ * Await every currently in-flight dispatch fan-out + sweep tick (task
287
+ * #1035). Exposed so tests driving the periodic timer can deterministically
288
+ * observe a tick's sweep settling without calling `stop()` (which would
289
+ * tear the timer down). Snapshots the set so work scheduled AFTER the call
290
+ * does not extend the wait.
291
+ */
292
+ async settle() {
293
+ await Promise.allSettled(Array.from(this.inFlight));
294
+ }
250
295
  /**
251
296
  * Abort the SSE generator, then drain all in-flight dispatch fan-outs and
252
297
  * any pending debounced buckets. Idempotent — a second call returns the
@@ -267,8 +312,14 @@ export class WftRouterDaemon {
267
312
  return;
268
313
  }
269
314
  this.phase = 'stopping';
270
- // 1. Stop reading new SSE events immediately.
315
+ // 1. Stop reading new SSE events immediately, and cancel the periodic
316
+ // re-sweep timers so no new tick fires during drain (task #1035). Any
317
+ // tick already in flight is awaited at step 4 via `inFlight`.
271
318
  this.abortController.abort();
319
+ for (const handle of this.intervalHandles) {
320
+ this.intervalScheduler.clear(handle);
321
+ }
322
+ this.intervalHandles.length = 0;
272
323
  // 2. Flush debounced buckets so their trailing-edge dispatch fires now
273
324
  // rather than waiting the full window on the way down.
274
325
  await this.debouncer.flushAll();
@@ -354,6 +405,28 @@ export class WftRouterDaemon {
354
405
  }
355
406
  }
356
407
  }
408
+ /**
409
+ * Periodic re-sweep (task #1035): schedule a timer that re-runs THIS rule's
410
+ * sweep every `intervalMs`. The tick runs `sweepRule` — the exact same
411
+ * query → predicate → at-most-one-dispatch path the cold-start sweep uses —
412
+ * so the deterministic `sweep:<rule>:<bucket>` identity + idempotency claim
413
+ * cap it at one kick per window (AC #1/#2). A tick error is isolated: it
414
+ * logs a WARN and the timer keeps firing (AC #4). Each tick's in-flight
415
+ * work is `track`ed so `stop()` drains it (AC #5).
416
+ */
417
+ startPeriodicSweep(rule, intervalMs) {
418
+ const handle = this.intervalScheduler.set(() => {
419
+ // A tick that fires mid-shutdown (timer not yet cleared) is a no-op.
420
+ if (this.phase !== 'running') {
421
+ return;
422
+ }
423
+ const tick = this.sweepRule(rule).catch((err) => {
424
+ this.logger.warn({ rule_name: rule.name, error: err instanceof Error ? err.message : String(err) }, 'wft_router_periodic_sweep_failed');
425
+ });
426
+ this.track(tick);
427
+ }, intervalMs);
428
+ this.intervalHandles.push(handle);
429
+ }
357
430
  /** Sweep ONE rule: query, predicate-match, then dispatch at most once. */
358
431
  async sweepRule(rule) {
359
432
  const match = await findFirstMatchingOpenTask(rule, {