@goodandready/dsh-kanban 0.2.27 → 0.2.29

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
@@ -94,12 +94,13 @@ import {
94
94
  withDefaults,
95
95
  CONFIG_DEFAULTS,
96
96
  CONFIG_HINTS,
97
+ buildPromptSectionText,
97
98
  rootOf,
98
99
  } from './config.js'
99
100
  import { eventHub } from './events.js'
100
101
  import { TASK_TEMPLATES, getTemplateById } from './templates.js'
101
102
 
102
- export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
103
+ export { COLUMN_ORDER, buildPromptSectionText, wipLimitField, withDefaults } from './config.js'
103
104
 
104
105
  /** Stable cordis plugin name (patch identifier). */
105
106
  export const name = '@goodandready/dsh-kanban'
@@ -370,12 +371,13 @@ export function apply(ctx, config) {
370
371
  }
371
372
  const cfg = getConfig()
372
373
  const sp = ctx.get('systemPrompt')
373
- if (cfg.announceToAgent !== false && sp && typeof sp.section === 'function') {
374
+ const promptText = buildPromptSectionText(cfg)
375
+ if (promptText && sp && typeof sp.section === 'function') {
374
376
  try {
375
377
  disposePromptSection = sp.section({
376
378
  name: 'plugin:dsh-kanban',
377
379
  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.',
380
+ text: promptText,
379
381
  })
380
382
  } catch (err) {
381
383
  ctx.logger?.warn?.(`dsh-kanban: failed to register system prompt section: ${err?.message}`)
@@ -658,6 +660,8 @@ export function apply(ctx, config) {
658
660
  path: '/dsh-kanban/presets',
659
661
  // Both services are optional in minimal environments. Empty lists render gracefully.
660
662
  handler: async (req, res) => {
663
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
664
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
661
665
  const presets = ctx.get('agentPresets')
662
666
  const permissions = ctx.get('permissionPresets')
663
667
  let agentPresets = []
@@ -694,6 +698,7 @@ export function apply(ctx, config) {
694
698
  path: '/dsh-kanban/sessions',
695
699
  handler: (req, res) => {
696
700
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
701
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
697
702
  reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) })
698
703
  },
699
704
  }), 'dsh-kanban: live sessions')
@@ -735,6 +740,7 @@ export function apply(ctx, config) {
735
740
  path: '/dsh-kanban/archive',
736
741
  handler: (req, res) => {
737
742
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
743
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
738
744
  reply(res, listArchive({ store }))
739
745
  },
740
746
  }), 'dsh-kanban: archive')
@@ -840,6 +846,7 @@ export function apply(ctx, config) {
840
846
  path: '/dsh-kanban/board',
841
847
  handler: (req, res) => {
842
848
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
849
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
843
850
  const url = urlOf(req)
844
851
  reply(res, buildBoard({
845
852
  store,
@@ -874,6 +881,7 @@ export function apply(ctx, config) {
874
881
  path: '/dsh-kanban/metrics',
875
882
  handler: (req, res) => {
876
883
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
884
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
877
885
  const url = urlOf(req)
878
886
  const days = Number(url.searchParams.get('staleDays'))
879
887
  reply(res, boardMetrics({
@@ -890,6 +898,7 @@ export function apply(ctx, config) {
890
898
  // Empty login indicates unconfigured or unreachable Gitea; client omits "Mine" filter.
891
899
  handler: async (req, res) => {
892
900
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
901
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
893
902
  reply(res, { login: await whoAmI() })
894
903
  },
895
904
  }), 'dsh-kanban: whoami')
@@ -899,6 +908,7 @@ export function apply(ctx, config) {
899
908
  path: '/dsh-kanban/export.csv',
900
909
  handler: async (req, res) => {
901
910
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
911
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
902
912
  const u = urlOf(req)
903
913
  const board = u.searchParams.get('board') || 'main'
904
914
  const csv = exportTasksCsv({ store, board })
@@ -931,6 +941,7 @@ export function apply(ctx, config) {
931
941
  path: '/dsh-kanban/templates',
932
942
  handler: (req, res) => {
933
943
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
944
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
934
945
  const u = new URL(req.url, 'http://localhost')
935
946
  const tid = u.searchParams.get('id')
936
947
  if (tid) {
@@ -947,9 +958,9 @@ export function apply(ctx, config) {
947
958
  kind: 'exact',
948
959
  path: '/dsh-kanban/snapshot',
949
960
  handler: async (req, res) => {
961
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
950
962
  if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
951
963
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
952
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
953
964
  try {
954
965
  const input = await readBody(req)
955
966
  const impResult = importBoard({ store, input })
@@ -990,6 +1001,8 @@ export function apply(ctx, config) {
990
1001
  if (parsed === undefined) { writeJson(res, 404, { error: 'not-found' }); return }
991
1002
  const { id, action } = parsed
992
1003
 
1004
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1005
+
993
1006
  if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
994
1007
  if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
995
1008
 
@@ -1000,7 +1013,6 @@ export function apply(ctx, config) {
1000
1013
  writeJson(res, 200, { text: buildStartMessage(task, getConfig()) })
1001
1014
  return
1002
1015
  }
1003
- if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1004
1016
  if (req.method === 'DELETE' && action === undefined) {
1005
1017
  const resObj = deleteTask({ store, id })
1006
1018
  if (resObj?.deleted) {
@@ -1381,6 +1393,7 @@ export function apply(ctx, config) {
1381
1393
  path: '/dsh-kanban/gitea/repos',
1382
1394
  handler: async (req, res) => {
1383
1395
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1396
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1384
1397
  const url = urlOf(req)
1385
1398
  reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
1386
1399
  },
@@ -1391,6 +1404,7 @@ export function apply(ctx, config) {
1391
1404
  path: '/dsh-kanban/gitea/issues',
1392
1405
  handler: async (req, res) => {
1393
1406
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1407
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1394
1408
  const url = urlOf(req)
1395
1409
  reply(res, await listImportable({
1396
1410
  gitea: git,
@@ -1428,6 +1442,7 @@ export function apply(ctx, config) {
1428
1442
  path: '/dsh-kanban/models',
1429
1443
  handler: async (req, res) => {
1430
1444
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1445
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1431
1446
  const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
1432
1447
  const llmService = ctx.get('llm')
1433
1448
  let providers = []
@@ -1456,6 +1471,7 @@ export function apply(ctx, config) {
1456
1471
  path: '/dsh-kanban/session',
1457
1472
  handler: (req, res) => {
1458
1473
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1474
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
1459
1475
  const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
1460
1476
  if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1461
1477
  reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))
package/lib/routes.js CHANGED
@@ -16,24 +16,63 @@ import { computeNextRun } from './cron.js'
16
16
  /** Maximum request payload size to prevent denial of service. */
17
17
  export const MAX_BODY_BYTES = 256 * 1024
18
18
 
19
- function isLoopbackAddress(addr) {
20
- if (!addr) return true
21
- return addr === '127.0.0.1' ||
22
- addr === '::1' ||
23
- addr === '::ffff:127.0.0.1' ||
24
- addr === 'localhost'
19
+ export function isLoopbackAddress(addr) {
20
+ if (!addr || typeof addr !== 'string') return false
21
+ const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
22
+ return (
23
+ clean === '127.0.0.1' ||
24
+ clean === '::1' ||
25
+ clean === 'localhost' ||
26
+ clean === 'localhost.' ||
27
+ clean.startsWith('127.')
28
+ )
29
+ }
30
+
31
+ export function isLanAddress(addr) {
32
+ if (!addr || typeof addr !== 'string') return false
33
+ const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
34
+ if (isLoopbackAddress(clean)) return true
35
+ if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
36
+ const m = clean.match(/^172\.(\d+)\./)
37
+ if (m) {
38
+ const octet = parseInt(m[1], 10)
39
+ if (octet >= 16 && octet <= 31) return true
40
+ }
41
+ return false
42
+ }
43
+
44
+ export function isTrustedHost(host) {
45
+ if (!host || typeof host !== 'string') return false
46
+ let hostname = host.trim()
47
+ if (hostname.startsWith('[')) {
48
+ const end = hostname.indexOf(']')
49
+ if (end > 0) hostname = hostname.slice(1, end)
50
+ } else {
51
+ hostname = hostname.split(':')[0]
52
+ }
53
+ hostname = hostname.toLowerCase()
54
+ if (!hostname) return false
55
+ if (hostname === 'localhost' || hostname === 'localhost.' || hostname.endsWith('.local')) return true
56
+ if (isLoopbackAddress(hostname) || isLanAddress(hostname)) return true
57
+ return false
25
58
  }
26
59
 
27
60
  /**
28
- * Cross-site request validation (#261).
29
- * Mutating endpoints modify local state and reject cross-site requests:
30
- * 1. Safe sec-fetch-site ('same-origin', 'same-site', 'none').
31
- * 2. Origin matches host header (when origin is present).
32
- * 3. Loopback remoteAddress (127.0.0.1, ::1, ::ffff:127.0.0.1).
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.
33
67
  */
34
68
  export function isTrustedRequest(req) {
35
69
  if (!req || !req.headers) return false
36
70
 
71
+ const host = req.headers.host
72
+ if (!host || !isTrustedHost(host)) {
73
+ return false
74
+ }
75
+
37
76
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
38
77
  const site = req.headers['sec-fetch-site']
39
78
 
@@ -42,47 +81,49 @@ export function isTrustedRequest(req) {
42
81
  return false
43
82
  }
44
83
 
45
- // 2. Browser request with declared site:
84
+ // 2. Validate origin against trusted hosts and host header
85
+ const origin = req.headers.origin
86
+ if (origin !== undefined) {
87
+ if (origin === 'null' || !origin) return false
88
+ try {
89
+ const parsed = new URL(origin)
90
+ if (!isTrustedHost(parsed.hostname)) return false
91
+ if (parsed.host && parsed.host !== host) return false
92
+ } catch {
93
+ return false
94
+ }
95
+ }
96
+
97
+ // 3. Validate referer against trusted hosts and host header if present
98
+ const referer = req.headers.referer
99
+ if (referer !== undefined && referer) {
100
+ try {
101
+ const parsedRef = new URL(referer)
102
+ if (!isTrustedHost(parsedRef.hostname)) return false
103
+ if (parsedRef.host && parsedRef.host !== host) return false
104
+ } catch {
105
+ return false
106
+ }
107
+ }
108
+
109
+ // 4. Browser request with declared site:
46
110
  // Must be same-origin or none (e.g. top-level navigation / bookmark / isolated subresource)
47
111
  if (site === 'same-origin' || site === 'none') {
48
- const origin = req.headers.origin
49
- if (origin !== undefined) {
50
- if (origin === 'null') return false
51
- try {
52
- const parsed = new URL(origin)
53
- const host = req.headers.host || ''
54
- if (parsed.host && parsed.host !== host) return false
55
- } catch {
56
- return false
57
- }
58
- }
59
- // Remote client must be loopback if present
60
- if (remote && !isLoopbackAddress(remote)) {
112
+ if (remote && !isLoopbackAddress(remote) && !isLanAddress(remote)) {
61
113
  return false
62
114
  }
63
115
  return true
64
116
  }
65
117
 
66
- // 3. Request without declared site:
118
+ // 5. Request without declared site:
67
119
  // Allowed strictly for loopback remoteAddress (DSH Desktop Electron forwardWebRequest, local CLI, curl)
68
120
  if (remote && isLoopbackAddress(remote)) {
69
- const origin = req.headers.origin
70
- if (origin !== undefined) {
71
- if (origin === 'null') return false
72
- try {
73
- const parsed = new URL(origin)
74
- const host = req.headers.host || ''
75
- if (parsed.host && parsed.host !== host) return false
76
- } catch {
77
- return false
78
- }
79
- }
80
121
  return true
81
122
  }
82
123
 
83
- // Fallback for test harnesses / mocks without socket where host header targets loopback
84
- if (!remote && req.headers.host) {
85
- const hostName = String(req.headers.host).split(':')[0]
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]
86
127
  if (isLoopbackAddress(hostName)) {
87
128
  return true
88
129
  }
package/lib/updater.js CHANGED
@@ -232,7 +232,6 @@ async function installExact(target, packageSpec, options) {
232
232
  '--profile',
233
233
  target.profileName,
234
234
  'add',
235
- '--config.minimumReleaseAge=0',
236
235
  packageSpec,
237
236
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
238
237
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
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",
@@ -18,6 +18,10 @@
18
18
  "README.ru.md",
19
19
  "LICENSE"
20
20
  ],
21
+ "scripts": {
22
+ "test": "node --test test/*.test.mjs",
23
+ "compat": "node tools/compat.mjs"
24
+ },
21
25
  "keywords": [
22
26
  "dsh",
23
27
  "dsh-plugin",
@@ -89,9 +93,5 @@
89
93
  "@deepseek-ai/dsh-tools": {
90
94
  "optional": true
91
95
  }
92
- },
93
- "scripts": {
94
- "test": "node --test test/*.test.mjs",
95
- "compat": "node tools/compat.mjs"
96
96
  }
97
- }
97
+ }