@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 +24 -1
- package/lib/index.js +21 -5
- package/lib/routes.js +81 -40
- package/lib/updater.js +0 -1
- package/package.json +6 -6
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: '
|
|
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
|
-
|
|
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:
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
*
|
|
30
|
-
* 1.
|
|
31
|
-
* 2. Origin
|
|
32
|
-
* 3.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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 &&
|
|
85
|
-
const hostName = String(
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
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
|
+
}
|