dsh-prompt-picker 0.7.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Security
6
+
7
+ - **Same-origin guard on the RPC route.** A webpage could make a browser POST
8
+ `text/plain` JSON at the loopback port (no preflight) and silently add or
9
+ check prompts. `/_dsh/prompt-picker/rpc` now refuses a request before
10
+ reading anything unless Origin (when present) equals the server's own
11
+ origin, Host is loopback or the server's own hostname, and POST bodies are
12
+ `application/json`; refusals answer `403 {code:'forbidden'}` and change
13
+ nothing.
14
+
15
+ ### Changed
16
+
17
+ - **Slim debug log by default.** The per-assembly line keeps `t`, `sub`,
18
+ `why`, `injected`; the context dump (`keys` + `ctx`) is only written with
19
+ `debugContext: true` in the row config. Fixes the stale
20
+ `full-throttle-debug.log` filename in the header comment.
21
+
3
22
  ## 0.7.0
4
23
 
5
24
  ### Added
package/README.md CHANGED
@@ -14,6 +14,7 @@ One bolt icon ⚡ in the composer toolbar: click it and check the system prompts
14
14
  - **Per-row scope**: all / main agent only / sub-agents only (click the chip at the end of a row; teammate sessions count as sub-agents)
15
15
  - **Manage**: add, edit (the built-in "Full Throttle" row included), delete one by one, or clear every saved row at once
16
16
  - **Survives restarts**: state lives in `~/.dsh/prompt-picker.json`
17
+ - **Local debug log**: each injection appends one slim JSON line (scope verdict + names) to `~/.dsh/prompt-picker-debug.log`; add `debugContext: true` to the row config for the full context dump. Local only, auto-resets past 256 KiB
17
18
 
18
19
  ## Install
19
20
 
package/README.zh.md CHANGED
@@ -14,6 +14,7 @@
14
14
  - **每条可设作用域**:全部 / 仅主 agent / 仅子 agent(点行尾圆片切换;teammate 会话也算子 agent)
15
15
  - **管理**:新增、修改(内置「火力全开」行也能改)、单条删除、一键全清自建条目
16
16
  - **重启不丢**:状态保存在 `~/.dsh/prompt-picker.json`
17
+ - **本地调试日志**:每次注入在 `~/.dsh/prompt-picker-debug.log` 记一行极简 JSON(作用域判定与注入名);需要完整上下文转储时在配置里加 `debugContext: true`。纯本地,超 256KiB 自动清零
17
18
 
18
19
  ## 安装
19
20
 
package/index.js CHANGED
@@ -9,11 +9,13 @@
9
9
  * built-in row is generated from config, can be edited like any row — the
10
10
  * edit persists as a `builtin` override in the state file and disappears
11
11
  * again once the row is back to its generated default — but cannot be
12
- * deleted. One dynamic system-prompt section receives
13
- * the assembly context; rows whose scope excludes the assembling agent
14
- * (main agent vs sub-agent) are filtered out. Because the main/sub
15
- * discrimination rule is being calibrated, every assembly appends one JSON
16
- * line to ~/.dsh/full-throttle-debug.log describing the observed context.
12
+ * deleted. One dynamic system-prompt section receives the assembly context;
13
+ * rows whose scope excludes the assembling agent (main agent vs sub-agent)
14
+ * are filtered out. Every assembly appends one slim JSON line (sub, why,
15
+ * injected names) to `$DSH_HOME/prompt-picker-debug.log`; the full context
16
+ * dump (keys + ctx) is only written with `debugContext: true`. The RPC
17
+ * route admits only same-origin callers: matching Origin (when present),
18
+ * loopback-or-own Host, and JSON Content-Type on POST.
17
19
  */
18
20
  import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
19
21
  import { appendFileSync, statSync, writeFileSync } from 'node:fs'
@@ -52,6 +54,15 @@ function messageOf(error) {
52
54
  return error instanceof Error ? error.message : String(error)
53
55
  }
54
56
 
57
+ /** Hostname part of an absolute origin URL, lowercase ('' when unparseable). */
58
+ function hostnameOf(url) {
59
+ try {
60
+ return new URL(url).hostname.toLowerCase()
61
+ } catch {
62
+ return ''
63
+ }
64
+ }
65
+
55
66
  function scopeOf(value) {
56
67
  return SCOPES.includes(value) ? value : 'both'
57
68
  }
@@ -158,6 +169,8 @@ class FullThrottleService {
158
169
  this.home = resolveDshHome(config?.dshHome)
159
170
  this.file = join(this.home, STATE_FILE)
160
171
  this.debugFile = join(this.home, DEBUG_FILE)
172
+ /** Full context dump in the debug log; slim line otherwise. */
173
+ this.debugContext = config?.debugContext === true
161
174
  this.builtin = { id: BUILTIN_ID, name: '火力全开', text: instructionsOf(config), scope: 'both' }
162
175
  /** The freshly generated built-in row; a persisted override is measured against it. */
163
176
  this.builtinDefault = { name: this.builtin.name, text: this.builtin.text, scope: this.builtin.scope }
@@ -398,21 +411,27 @@ class FullThrottleService {
398
411
  }
399
412
  }
400
413
 
401
- /** One calibration line per assembly; the file restarts past 256 KiB. */
414
+ /**
415
+ * One JSON line per assembly (sub, why, injected names); the compact
416
+ * context dump (keys + ctx) only with `debugContext: true`. The file
417
+ * restarts past 256 KiB.
418
+ */
402
419
  logAssembly(context, decision, injected) {
403
420
  try {
404
421
  try {
405
422
  if (statSync(this.debugFile).size > 256 * 1024) writeFileSync(this.debugFile, '')
406
423
  } catch {}
407
- const line = JSON.stringify({
424
+ const line = {
408
425
  t: new Date().toISOString(),
409
426
  sub: decision.sub,
410
427
  why: decision.why,
411
428
  injected,
412
- keys: context !== null && typeof context === 'object' ? Object.keys(context).slice(0, 24) : [],
413
- ctx: debugRepr(context),
414
- }) + '\n'
415
- appendFileSync(this.debugFile, line, 'utf8')
429
+ }
430
+ if (this.debugContext) {
431
+ line.keys = context !== null && typeof context === 'object' ? Object.keys(context).slice(0, 24) : []
432
+ line.ctx = debugRepr(context)
433
+ }
434
+ appendFileSync(this.debugFile, JSON.stringify(line) + '\n', 'utf8')
416
435
  } catch {}
417
436
  }
418
437
 
@@ -442,10 +461,39 @@ async function readBody(req, maxBytes = 256 * 1024) {
442
461
  return Buffer.concat(chunks).toString('utf8')
443
462
  }
444
463
 
445
- /** Same-origin RPC the browser half calls: GET = state, POST = toggle|clear|add|setScope|update|delete|deleteAll. */
464
+ /** Hostnames a loopback-served RPC also admits. */
465
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1'])
466
+
467
+ /**
468
+ * Same-origin RPC the browser half calls: GET = state, POST = toggle|clear|add|setScope|update|delete|deleteAll.
469
+ * Cross-site callers are rejected before anything is read: a browser can be
470
+ * made to POST `text/plain` JSON at this port from any webpage, so Origin
471
+ * (when present) must equal the server's own origin, Host must be loopback
472
+ * or the server's own hostname, and POST bodies must be `application/json`.
473
+ */
446
474
  class RpcBackend {
447
- constructor(service) {
475
+ constructor(service, originOf) {
448
476
  this.service = service
477
+ this.originOf = originOf
478
+ }
479
+
480
+ /** '' when the request may pass; otherwise the reason it was refused. */
481
+ admit(req) {
482
+ const headers = req.headers ?? {}
483
+ const expected = this.originOf()
484
+ const origin = typeof headers.origin === 'string' ? headers.origin : ''
485
+ if (origin !== '' && origin !== expected) return 'origin ' + origin
486
+ const host = typeof headers.host === 'string' ? headers.host : ''
487
+ if (host !== '') {
488
+ const hostname = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]
489
+ const ownHostname = hostnameOf(expected)
490
+ if (hostname.toLowerCase() !== ownHostname && !LOOPBACK_HOSTS.has(hostname.toLowerCase())) return 'host ' + host
491
+ }
492
+ if (req.method === 'POST') {
493
+ const type = String(headers['content-type'] ?? '').toLowerCase()
494
+ if (!type.startsWith('application/json')) return 'content-type ' + (type === '' ? '(none)' : type)
495
+ }
496
+ return ''
449
497
  }
450
498
 
451
499
  responseJson(res, status, body) {
@@ -458,6 +506,11 @@ class RpcBackend {
458
506
  }
459
507
 
460
508
  async handle(req, res) {
509
+ const denied = this.admit(req)
510
+ if (denied !== '') {
511
+ this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'cross-site request rejected: ' + denied } })
512
+ return
513
+ }
461
514
  if (req.method === 'GET') {
462
515
  try {
463
516
  this.responseJson(res, 200, { ok: true, value: await this.service.getState() })
@@ -521,7 +574,7 @@ export function apply(ctx, config) {
521
574
  'prompt-picker.prompt-section',
522
575
  )
523
576
  ctx.inject(['webServer'], (webCtx) => {
524
- const rpc = new RpcBackend(service)
577
+ const rpc = new RpcBackend(service, () => webCtx.webServer.getOrigin())
525
578
  webCtx.effect(() => {
526
579
  const off = webCtx.webServer.register({
527
580
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prompt-picker",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Prompt picker for DeepSeek Harness: multi-select system-prompt injections with per-row scopes (both/main/sub), a two-level add/edit footer menu, in-place edit of every row including the built-in one, single-row delete and clear-all, one bolt icon in the composer.",
5
5
  "type": "module",
6
6
  "main": "index.js",