@goodandready/dsh-kanban 0.2.28 → 0.2.30

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/lib/config.js CHANGED
@@ -107,7 +107,7 @@ export const CONFIG_HINTS = {
107
107
  progressDumpEnabled: 'Enable PROGRESSDUMP state checkpoints between agent sessions.',
108
108
  permissionGateEnabled: 'Require human confirmation before launching elevated permission tasks.',
109
109
  sessionDefaultPermission: 'Default permission tier for task sessions (read-only, workspace-write, danger-full-access).',
110
- announceToAgent: 'Post stage change announcement messages directly into task agent session.',
110
+ announceToAgent: 'Inject board instructions and handover guidance into the agent system prompt.',
111
111
  maxConcurrentSessions: 'Maximum number of simultaneous active agent sessions on the board.',
112
112
  workspaceClaimBoundaries: 'Enforce workspace ownership boundaries to prevent cross-workspace task execution.',
113
113
  autoSyncSessions: 'Automatically sync and attach live background agent sessions to cards.',
@@ -178,3 +178,26 @@ export function columnNamesOf(config) {
178
178
  }
179
179
  return out
180
180
  }
181
+
182
+ /**
183
+ * Build prompt section text based on enabled board features.
184
+ * Returns null if no features requiring agent guidance are active.
185
+ *
186
+ * @param {object} cfg configuration
187
+ * @returns {string|null}
188
+ */
189
+ export function buildPromptSectionText(cfg) {
190
+ if (cfg?.announceToAgent === false) return null
191
+ const parts = []
192
+ if (cfg?.boardToolEnabled === true) {
193
+ parts.push(
194
+ 'The dsh-kanban task board is available in the system. When working on tasks, you can move cards using the board_move tool and manage the plan checklist via board_plan.'
195
+ )
196
+ }
197
+ if (cfg?.progressDumpEnabled === true) {
198
+ parts.push(
199
+ 'When context is running low or a milestone is reached, output a <<<PROGRESSDUMP\nGoal: ...\nProgress: ...\nNext steps: ...\n>>>PROGRESSDUMP block to hand over to the next session.'
200
+ )
201
+ }
202
+ return parts.length > 0 ? parts.join(' ') : null
203
+ }
package/lib/index.js CHANGED
@@ -12,6 +12,8 @@ import { registerPluginUpdater } from './updater.js'
12
12
  import {
13
13
  MAX_BODY_BYTES,
14
14
  isTrustedRequest,
15
+ rejectUntrustedRequest,
16
+ getRejectionStatus,
15
17
  parseTaskPath,
16
18
  buildBoard,
17
19
  applyMove,
@@ -94,12 +96,13 @@ import {
94
96
  withDefaults,
95
97
  CONFIG_DEFAULTS,
96
98
  CONFIG_HINTS,
99
+ buildPromptSectionText,
97
100
  rootOf,
98
101
  } from './config.js'
99
102
  import { eventHub } from './events.js'
100
103
  import { TASK_TEMPLATES, getTemplateById } from './templates.js'
101
104
 
102
- export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
105
+ export { COLUMN_ORDER, buildPromptSectionText, wipLimitField, withDefaults } from './config.js'
103
106
 
104
107
  /** Stable cordis plugin name (patch identifier). */
105
108
  export const name = '@goodandready/dsh-kanban'
@@ -370,12 +373,13 @@ export function apply(ctx, config) {
370
373
  }
371
374
  const cfg = getConfig()
372
375
  const sp = ctx.get('systemPrompt')
373
- if (cfg.announceToAgent !== false && sp && typeof sp.section === 'function') {
376
+ const promptText = buildPromptSectionText(cfg)
377
+ if (promptText && sp && typeof sp.section === 'function') {
374
378
  try {
375
379
  disposePromptSection = sp.section({
376
380
  name: 'plugin:dsh-kanban',
377
381
  order: 200,
378
- text: 'The dsh-kanban task board is available in the system. When working on tasks, you can move cards using the board_move tool and manage the plan checklist via board_plan. When context is running low or a milestone is reached, output a <<<PROGRESSDUMP\nGoal: ...\nProgress: ...\nNext steps: ...\n>>>PROGRESSDUMP block to hand over to the next session.',
382
+ text: promptText,
379
383
  })
380
384
  } catch (err) {
381
385
  ctx.logger?.warn?.(`dsh-kanban: failed to register system prompt section: ${err?.message}`)
@@ -632,12 +636,36 @@ export function apply(ctx, config) {
632
636
  return () => { rearmers.delete(arm); clearInterval(timer) }
633
637
  }, 'dsh-kanban: sync timer')
634
638
 
639
+ const resolveConnection = () => {
640
+ try {
641
+ if (ctx && typeof ctx.get === 'function') {
642
+ const c = ctx.get('connection')
643
+ if (c) return c
644
+ }
645
+ } catch { /* ignore */ }
646
+ try {
647
+ if (ctx && ctx.connection) {
648
+ return ctx.connection
649
+ }
650
+ } catch { /* ignore */ }
651
+ return undefined
652
+ }
653
+
654
+ const routeOptions = () => ({
655
+ get connection() {
656
+ return resolveConnection()
657
+ },
658
+ })
659
+
660
+ const isTrusted = (req) => isTrustedRequest(req, routeOptions())
661
+ const rejectUntrusted = (req, res) => rejectUntrustedRequest(req, res, routeOptions())
662
+
635
663
  ctx.effect(() => ctx.webServer.register({
636
664
  kind: 'exact',
637
665
  path: '/dsh-kanban/project-task',
638
666
  handler: async (req, res) => {
639
667
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
640
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
668
+ if (rejectUntrusted(req, res)) return
641
669
  try {
642
670
  const input = await readBody(req)
643
671
  reply(res, await createProjectTask({
@@ -659,7 +687,7 @@ export function apply(ctx, config) {
659
687
  // Both services are optional in minimal environments. Empty lists render gracefully.
660
688
  handler: async (req, res) => {
661
689
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
662
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
690
+ if (rejectUntrusted(req, res)) return
663
691
  const presets = ctx.get('agentPresets')
664
692
  const permissions = ctx.get('permissionPresets')
665
693
  let agentPresets = []
@@ -696,7 +724,7 @@ export function apply(ctx, config) {
696
724
  path: '/dsh-kanban/sessions',
697
725
  handler: (req, res) => {
698
726
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
699
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
727
+ if (rejectUntrusted(req, res)) return
700
728
  reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) })
701
729
  },
702
730
  }), 'dsh-kanban: live sessions')
@@ -706,7 +734,7 @@ export function apply(ctx, config) {
706
734
  path: '/dsh-kanban/batch',
707
735
  handler: async (req, res) => {
708
736
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
709
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
737
+ if (rejectUntrusted(req, res)) return
710
738
  try {
711
739
  const input = await readBody(req)
712
740
  const ids = Array.isArray(input.ids) ? input.ids : []
@@ -738,7 +766,7 @@ export function apply(ctx, config) {
738
766
  path: '/dsh-kanban/archive',
739
767
  handler: (req, res) => {
740
768
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
741
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
769
+ if (rejectUntrusted(req, res)) return
742
770
  reply(res, listArchive({ store }))
743
771
  },
744
772
  }), 'dsh-kanban: archive')
@@ -748,7 +776,7 @@ export function apply(ctx, config) {
748
776
  path: '/dsh-kanban/sync',
749
777
  handler: async (req, res) => {
750
778
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
751
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
779
+ if (rejectUntrusted(req, res)) return
752
780
  try {
753
781
  writeJson(res, 200, await runSync())
754
782
  } catch (error) {
@@ -844,7 +872,7 @@ export function apply(ctx, config) {
844
872
  path: '/dsh-kanban/board',
845
873
  handler: (req, res) => {
846
874
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
847
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
875
+ if (rejectUntrusted(req, res)) return
848
876
  const url = urlOf(req)
849
877
  reply(res, buildBoard({
850
878
  store,
@@ -879,7 +907,7 @@ export function apply(ctx, config) {
879
907
  path: '/dsh-kanban/metrics',
880
908
  handler: (req, res) => {
881
909
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
882
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
910
+ if (rejectUntrusted(req, res)) return
883
911
  const url = urlOf(req)
884
912
  const days = Number(url.searchParams.get('staleDays'))
885
913
  reply(res, boardMetrics({
@@ -896,7 +924,7 @@ export function apply(ctx, config) {
896
924
  // Empty login indicates unconfigured or unreachable Gitea; client omits "Mine" filter.
897
925
  handler: async (req, res) => {
898
926
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
899
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
927
+ if (rejectUntrusted(req, res)) return
900
928
  reply(res, { login: await whoAmI() })
901
929
  },
902
930
  }), 'dsh-kanban: whoami')
@@ -906,7 +934,7 @@ export function apply(ctx, config) {
906
934
  path: '/dsh-kanban/export.csv',
907
935
  handler: async (req, res) => {
908
936
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
909
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
937
+ if (rejectUntrusted(req, res)) return
910
938
  const u = urlOf(req)
911
939
  const board = u.searchParams.get('board') || 'main'
912
940
  const csv = exportTasksCsv({ store, board })
@@ -923,7 +951,7 @@ export function apply(ctx, config) {
923
951
  kind: 'exact',
924
952
  path: '/dsh-kanban/events',
925
953
  handler: (req, res) => {
926
- eventHub.handleSseRequest(req, res, { isTrustedRequest })
954
+ eventHub.handleSseRequest(req, res, { isTrustedRequest: isTrusted })
927
955
  },
928
956
  }), 'dsh-kanban: sse events stream')
929
957
 
@@ -939,7 +967,7 @@ export function apply(ctx, config) {
939
967
  path: '/dsh-kanban/templates',
940
968
  handler: (req, res) => {
941
969
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
942
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
970
+ if (rejectUntrusted(req, res)) return
943
971
  const u = new URL(req.url, 'http://localhost')
944
972
  const tid = u.searchParams.get('id')
945
973
  if (tid) {
@@ -956,7 +984,7 @@ export function apply(ctx, config) {
956
984
  kind: 'exact',
957
985
  path: '/dsh-kanban/snapshot',
958
986
  handler: async (req, res) => {
959
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
987
+ if (rejectUntrusted(req, res)) return
960
988
  if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
961
989
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
962
990
  try {
@@ -982,7 +1010,7 @@ export function apply(ctx, config) {
982
1010
  // Path without ID handles local task creation.
983
1011
  if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
984
1012
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
985
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1013
+ if (rejectUntrusted(req, res)) return
986
1014
  try {
987
1015
  const resObj = createTask({ store, input: await readBody(req) })
988
1016
  if (resObj?.task) {
@@ -999,7 +1027,7 @@ export function apply(ctx, config) {
999
1027
  if (parsed === undefined) { writeJson(res, 404, { error: 'not-found' }); return }
1000
1028
  const { id, action } = parsed
1001
1029
 
1002
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1030
+ if (rejectUntrusted(req, res)) return
1003
1031
 
1004
1032
  if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
1005
1033
  if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
@@ -1391,7 +1419,7 @@ export function apply(ctx, config) {
1391
1419
  path: '/dsh-kanban/gitea/repos',
1392
1420
  handler: async (req, res) => {
1393
1421
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1394
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1422
+ if (rejectUntrusted(req, res)) return
1395
1423
  const url = urlOf(req)
1396
1424
  reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
1397
1425
  },
@@ -1402,7 +1430,7 @@ export function apply(ctx, config) {
1402
1430
  path: '/dsh-kanban/gitea/issues',
1403
1431
  handler: async (req, res) => {
1404
1432
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1405
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1433
+ if (rejectUntrusted(req, res)) return
1406
1434
  const url = urlOf(req)
1407
1435
  reply(res, await listImportable({
1408
1436
  gitea: git,
@@ -1419,7 +1447,7 @@ export function apply(ctx, config) {
1419
1447
  path: '/dsh-kanban/import',
1420
1448
  handler: async (req, res) => {
1421
1449
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1422
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1450
+ if (rejectUntrusted(req, res)) return
1423
1451
  try {
1424
1452
  const input = await readBody(req)
1425
1453
  reply(res, await importIssue({
@@ -1440,7 +1468,7 @@ export function apply(ctx, config) {
1440
1468
  path: '/dsh-kanban/models',
1441
1469
  handler: async (req, res) => {
1442
1470
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1443
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1471
+ if (rejectUntrusted(req, res)) return
1444
1472
  const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
1445
1473
  const llmService = ctx.get('llm')
1446
1474
  let providers = []
@@ -1469,7 +1497,7 @@ export function apply(ctx, config) {
1469
1497
  path: '/dsh-kanban/session',
1470
1498
  handler: (req, res) => {
1471
1499
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1472
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1500
+ if (rejectUntrusted(req, res)) return
1473
1501
  const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
1474
1502
  if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1475
1503
  reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))
package/lib/routes.js CHANGED
@@ -57,40 +57,75 @@ export function isTrustedHost(host) {
57
57
  return false
58
58
  }
59
59
 
60
+ export function getClientIp(req) {
61
+ return (
62
+ req?.socket?.remoteAddress ||
63
+ req?.connection?.remoteAddress ||
64
+ req?.info?.remoteAddress ||
65
+ ''
66
+ )
67
+ }
68
+
60
69
  /**
61
- * Cross-site request and DNS rebinding validation (#261, #369).
62
- * Endpoints reject cross-site and DNS-rebinding requests:
63
- * 1. Host header is required and must target a trusted host (loopback, LAN, .local).
64
- * 2. Origin/Referer (if present) must target a trusted host and match Host header.
65
- * 3. Safe sec-fetch-site ('same-origin', 'same-site', 'none').
66
- * 4. Loopback or private LAN remoteAddress.
70
+ * Cross-site request, DNS rebinding, and authentication validation (#261, #369, #384).
71
+ *
72
+ * Requirements:
73
+ * 1. If DSH connection service is available (options.connection), requestRejection(req)
74
+ * is the authoritative gate for browser session authentication and trusted network access.
75
+ * If rejection is returned, the numeric status (e.g. 401 or 403) is returned.
76
+ * 2. Host header is required and must target a trusted host (loopback, LAN, .local).
77
+ * 3. Cross-site requests ('sec-fetch-site: cross-site' or origin mismatch) are rejected with 403.
78
+ * 4. Origin & Referer headers (if present) must match Host or a trusted host.
79
+ * 5. Network boundary validation:
80
+ * - Loopback callers (127.0.0.1, ::1) are allowed.
81
+ * - Non-loopback / LAN callers require valid connection authentication (fail-closed, return 401).
82
+ * - Unknown remote address (socket present but empty remoteAddress) fails closed with 403.
83
+ *
84
+ * @param {import('node:http').IncomingMessage} req
85
+ * @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
86
+ * @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
67
87
  */
68
- export function isTrustedRequest(req) {
69
- if (!req || !req.headers) return false
88
+ export function getRejectionStatus(req, options = {}) {
89
+ const connection = options?.connection
90
+ if (connection && typeof connection.requestRejection === 'function') {
91
+ try {
92
+ const rejection = connection.requestRejection(req)
93
+ if (rejection !== undefined) {
94
+ if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
95
+ return rejection
96
+ }
97
+ return rejection === 401 ? 401 : 403
98
+ }
99
+ } catch {
100
+ // Fail closed if connection service throws
101
+ return 403
102
+ }
103
+ }
104
+
105
+ if (!req || !req.headers) return 403
70
106
 
71
107
  const host = req.headers.host
72
108
  if (!host || !isTrustedHost(host)) {
73
- return false
109
+ return 403
74
110
  }
75
111
 
76
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
77
112
  const site = req.headers['sec-fetch-site']
78
113
 
79
114
  // 1. Explicit cross-site or same-site requests are rejected
80
115
  if (site === 'cross-site' || site === 'same-site') {
81
- return false
116
+ return 403
82
117
  }
83
118
 
84
119
  // 2. Validate origin against trusted hosts and host header
85
120
  const origin = req.headers.origin
86
121
  if (origin !== undefined) {
87
- if (origin === 'null' || !origin) return false
122
+ if (origin === 'null' || !origin) return 403
88
123
  try {
89
124
  const parsed = new URL(origin)
90
- if (!isTrustedHost(parsed.hostname)) return false
91
- if (parsed.host && parsed.host !== host) return false
125
+ if (!isTrustedHost(parsed.hostname)) return 403
126
+ if (parsed.host && parsed.host !== host) return 403
92
127
  } catch {
93
- return false
128
+ return 403
94
129
  }
95
130
  }
96
131
 
@@ -99,36 +134,74 @@ export function isTrustedRequest(req) {
99
134
  if (referer !== undefined && referer) {
100
135
  try {
101
136
  const parsedRef = new URL(referer)
102
- if (!isTrustedHost(parsedRef.hostname)) return false
103
- if (parsedRef.host && parsedRef.host !== host) return false
137
+ if (!isTrustedHost(parsedRef.hostname)) return 403
138
+ if (parsedRef.host && parsedRef.host !== host) return 403
104
139
  } catch {
105
- return false
140
+ return 403
106
141
  }
107
142
  }
108
143
 
109
- // 4. Browser request with declared site:
110
- // Must be same-origin or none (e.g. top-level navigation / bookmark / isolated subresource)
111
- if (site === 'same-origin' || site === 'none') {
112
- if (remote && !isLoopbackAddress(remote) && !isLanAddress(remote)) {
113
- return false
144
+ // 4. Validate remote address and network boundary
145
+ const hasSocket = !!(req.socket || req.connection)
146
+ const remote = getClientIp(req)
147
+
148
+ if (hasSocket) {
149
+ if (!remote) {
150
+ return 403
151
+ }
152
+ if (!isLoopbackAddress(remote) && !isLanAddress(remote)) {
153
+ return 403
154
+ }
155
+ // Remote / LAN callers without connection authentication are rejected (#384)
156
+ if (!isLoopbackAddress(remote)) {
157
+ if (!connection) {
158
+ return 401
159
+ }
160
+ }
161
+ } else if (!connection) {
162
+ // Synthetic mock request without socket: allow loopback host for unit tests
163
+ const hostname = String(host).split(':')[0].toLowerCase()
164
+ if (!isLoopbackAddress(hostname) && hostname !== 'localhost') {
165
+ return 401
114
166
  }
115
- return true
116
167
  }
117
168
 
118
- // 5. Request without declared site:
119
- // Allowed strictly for loopback remoteAddress (DSH Desktop Electron forwardWebRequest, local CLI, curl)
120
- if (remote && isLoopbackAddress(remote)) {
121
- return true
169
+ // 5. Browser request with declared site:
170
+ // Must be same-origin or none
171
+ if (site && site !== 'same-origin' && site !== 'none') {
172
+ return 403
122
173
  }
123
174
 
124
- // 6. Fallback for test harnesses / mocks without socket where host header targets loopback
125
- if (!remote && host) {
126
- const hostName = String(host).split(':')[0]
127
- if (isLoopbackAddress(hostName)) {
128
- return true
129
- }
175
+ // 6. Request without declared site from non-loopback requires connection
176
+ if (!site && remote && !isLoopbackAddress(remote) && !connection) {
177
+ return 401
130
178
  }
131
179
 
180
+ return undefined
181
+ }
182
+
183
+ export function isTrustedRequest(req, options = {}) {
184
+ return getRejectionStatus(req, options) === undefined
185
+ }
186
+
187
+ export function rejectUntrustedRequest(req, res, options = {}) {
188
+ const status = getRejectionStatus(req, options)
189
+ if (status !== undefined) {
190
+ try {
191
+ res.writeHead(status, {
192
+ 'Content-Type': 'application/json; charset=utf-8',
193
+ 'Cache-Control': 'no-store',
194
+ })
195
+ res.end(
196
+ JSON.stringify({
197
+ error: status === 401 ? 'unauthorized' : 'cross-site',
198
+ })
199
+ )
200
+ } catch {
201
+ /* socket closed */
202
+ }
203
+ return true
204
+ }
132
205
  return false
133
206
  }
134
207
 
package/lib/updater.js CHANGED
@@ -42,8 +42,20 @@ function isPrivateLan(value) {
42
42
  )
43
43
  }
44
44
 
45
- export function isTrustedUpdateRequest(request) {
45
+ export function isTrustedUpdateRequest(request, targetCtx = null) {
46
46
  if (header(request, UPDATE_HEADER) !== '1') return false
47
+ const conn = (targetCtx && typeof targetCtx.get === 'function' ? targetCtx.get('connection') : null)
48
+ || targetCtx?.connection
49
+ || request?.connectionService
50
+ if (conn && typeof conn.requestRejection === 'function') {
51
+ let rejection
52
+ try {
53
+ rejection = conn.requestRejection(request)
54
+ } catch {
55
+ return false
56
+ }
57
+ if (rejection !== undefined) return false
58
+ }
47
59
  const remote = request.socket?.remoteAddress
48
60
  if (!isLoopback(remote) && !isPrivateLan(remote)) return false
49
61
  const site = header(request, 'sec-fetch-site')
@@ -300,7 +312,7 @@ export function registerPluginUpdater(ctx, options) {
300
312
  response.end()
301
313
  return
302
314
  }
303
- if (!isTrustedUpdateRequest(request)) {
315
+ if (!isTrustedUpdateRequest(request, ctx)) {
304
316
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
305
317
  return
306
318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",