@dsh-fly/dsh-plugin-fly-mode 0.1.8 → 0.1.10
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/README.md +3 -3
- package/cordis.patch.yml +0 -1
- package/package.json +2 -2
- package/tools/dsh-plugin-fly-artifact/index.js +21 -2
- package/tools/dsh-plugin-fly-binary/index.js +110 -22
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +3 -0
- package/tools/dsh-plugin-fly-blackbox/index.js +490 -5
- package/tools/dsh-plugin-fly-diag/index.js +177 -28
- package/tools/dsh-plugin-fly-pwn/index.js +91 -9
- package/tools/dsh-plugin-fly-recon/index.js +117 -16
- package/tools/dsh-plugin-fly-reverse/index.js +132 -18
|
@@ -30,7 +30,26 @@ function shq(value) {
|
|
|
30
30
|
* `TypeError: shell.run is not a function` on every call, in every engine
|
|
31
31
|
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
32
32
|
* names a seam method the executor does not have.
|
|
33
|
+
*
|
|
34
|
+
* **失败面的口径是"大声"**(Q5):这一包全是**活读数**,"没量到"绝不能读成"没有" ——
|
|
35
|
+
* · 缺 `ps` / `lsof` / `netstat` / `ping` 时各自走已登记的路径:能换方法的换(`lsof` 不通换
|
|
36
|
+
* `netstat`,并在结果里说清"netstat 的表更粗、没有进程名/PID"),换不了就 `requireOk` 抛错;
|
|
37
|
+
* · "成功却没输出"是一等状态:`diag_procs` 会明说"ps 成功却没有任何输出 ⇒ 这张表是被读空的";
|
|
38
|
+
* · `diag_net_watch` 读到**半个样本**就跳过并**报出跳过数**(静默丢数据比丢数据更糟),
|
|
39
|
+
* 停止是**异步**的(写完信号文件就返回),这些都在各支的 `limitations` 里。
|
|
33
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-14)。
|
|
43
|
+
*
|
|
44
|
+
* 与六个安全包里的同名助手同源(§5.4.1/§5.4.3):不插 `--`(本机 objdump/base64/otool 不支持),
|
|
45
|
+
* 而是把它变成一个普通相对路径。这里用于 `diag_net_watch` 的 `dir` —— 同一族的**无伤**版本
|
|
46
|
+
* (裸 `-o/tmp/x` 进 `mkdir` 只会响亮报错),本笔是**一致性收尾**,不是安全修复。
|
|
47
|
+
*/
|
|
48
|
+
function safePath(value) {
|
|
49
|
+
const text = String(value ?? '')
|
|
50
|
+
return text.startsWith('-') ? './' + text : text
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
35
54
|
const result = await (await shell.execute(shell.resolve({
|
|
36
55
|
command: argv.map(shq).join(' '),
|
|
@@ -60,9 +79,19 @@ async function runCmd(shell, argv, exec, opts = {}) {
|
|
|
60
79
|
*/
|
|
61
80
|
function requireOk(run, what) {
|
|
62
81
|
if (run.exitCode === 0) return run.stdout
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
// 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
|
|
83
|
+
// 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
|
|
84
|
+
// 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
|
|
85
|
+
const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
|
|
86
|
+
const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
|
|
87
|
+
const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
|
|
88
|
+
const missing = notInstalled ? ' — the command is not installed on this host' : ''
|
|
89
|
+
// 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
|
|
90
|
+
// 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
|
|
91
|
+
const next = notInstalled
|
|
92
|
+
? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
|
|
93
|
+
: 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
|
|
94
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
|
|
66
95
|
error.name = 'CommandError'
|
|
67
96
|
throw error
|
|
68
97
|
}
|
|
@@ -382,8 +411,7 @@ export function apply(ctx) {
|
|
|
382
411
|
async execute(_args, exec) {
|
|
383
412
|
exec.signal.throwIfAborted()
|
|
384
413
|
const mem = os.totalmem()
|
|
385
|
-
|
|
386
|
-
{
|
|
414
|
+
const hostSnapshot = {
|
|
387
415
|
hostname: os.hostname(),
|
|
388
416
|
platform: os.platform(),
|
|
389
417
|
release: os.release(),
|
|
@@ -394,10 +422,18 @@ export function apply(ctx) {
|
|
|
394
422
|
freeMemGB: +(os.freemem() / 1024 ** 3).toFixed(2),
|
|
395
423
|
loadavg: os.loadavg().map((n) => +n.toFixed(2)),
|
|
396
424
|
uptimeHours: +(os.uptime() / 3600).toFixed(2),
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
425
|
+
}
|
|
426
|
+
return JSON.stringify({
|
|
427
|
+
tool: 'diag_host',
|
|
428
|
+
target: 'this host',
|
|
429
|
+
operation: 'read local host identity, CPU and memory counters (read-only, no probing)',
|
|
430
|
+
observed: hostSnapshot,
|
|
431
|
+
confidence: 'observed',
|
|
432
|
+
limitations: [
|
|
433
|
+
'只看得到本机:没有对端/邻居的信息(对端要用 recon_* 或 diag_net_rtt 单独量)',
|
|
434
|
+
'负载与内存是瞬时读数:不说明趋势,也不说明是不是刚刚才变(趋势要用 diag_net_watch 那类记录仪)',
|
|
435
|
+
],
|
|
436
|
+
}, null, 2)
|
|
401
437
|
},
|
|
402
438
|
}))
|
|
403
439
|
|
|
@@ -412,11 +448,24 @@ export function apply(ctx) {
|
|
|
412
448
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
413
449
|
},
|
|
414
450
|
async execute(args, exec) {
|
|
451
|
+
// 证据形状(规划 §5.1):两支都产出信封。
|
|
452
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
453
|
+
tool: 'diag_procs',
|
|
454
|
+
target: 'this host',
|
|
455
|
+
operation: 'take one ps aux snapshot and return the top rows by %CPU',
|
|
456
|
+
observed,
|
|
457
|
+
confidence: 'observed',
|
|
458
|
+
limitations,
|
|
459
|
+
}, null, 2)
|
|
415
460
|
const limit = Math.max(1, Math.min(50, Number(args.limit) || 15))
|
|
416
461
|
const stdout = requireOk(await runCmd(shell, ['ps', 'aux'], exec, { stdoutMaxBytes: 1048576 }), 'ps aux')
|
|
417
462
|
const lines = stdout.split('\n').filter(Boolean)
|
|
418
463
|
const [header, ...rest] = lines
|
|
419
|
-
if (header === undefined)
|
|
464
|
+
if (header === undefined) {
|
|
465
|
+
return envelope({ method: 'ps', rows: 0, dump: 'ps aux produced no output (the command succeeded, which is suspicious)' }, [
|
|
466
|
+
'ps 成功却没有任何输出 ⇒ 这张表是被读空的:**不能**读成「本机没有进程」',
|
|
467
|
+
])
|
|
468
|
+
}
|
|
420
469
|
const body = rest
|
|
421
470
|
.sort((a, b) => {
|
|
422
471
|
const pa = parseFloat(a.split(/\s+/)[2] || '0')
|
|
@@ -424,7 +473,10 @@ export function apply(ctx) {
|
|
|
424
473
|
return pb - pa
|
|
425
474
|
})
|
|
426
475
|
.slice(0, limit)
|
|
427
|
-
return [header, ...body].join('\n')
|
|
476
|
+
return envelope({ method: 'ps', limit, rows: body.length, dump: [header, ...body].join('\n') }, [
|
|
477
|
+
'ps aux 的表格原样返回,本工具只做两件事:按第 3 列(%CPU)降序、取前 limit 行',
|
|
478
|
+
'这是**一次快照**:不说明进程的历史、持续时间或趋势(要趋势得自己隔一段时间再取一次)',
|
|
479
|
+
])
|
|
428
480
|
},
|
|
429
481
|
}))
|
|
430
482
|
|
|
@@ -437,12 +489,33 @@ export function apply(ctx) {
|
|
|
437
489
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
438
490
|
},
|
|
439
491
|
async execute(_args, exec) {
|
|
492
|
+
// 证据形状(规划 §5.1):三支都产出信封。两条方法(lsof / netstat)与"什么都没读到"都要说清。
|
|
493
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
494
|
+
tool: 'diag_net',
|
|
495
|
+
target: 'this host',
|
|
496
|
+
operation: 'snapshot TCP listening sockets (read-only, no probing)',
|
|
497
|
+
observed,
|
|
498
|
+
confidence: 'observed',
|
|
499
|
+
limitations,
|
|
500
|
+
}, null, 2)
|
|
440
501
|
const lsof = await runCmd(shell, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN'], exec)
|
|
441
|
-
if (lsof.exitCode === 0)
|
|
502
|
+
if (lsof.exitCode === 0) {
|
|
503
|
+
return envelope({ method: 'lsof', dump: lsof.stdout.slice(0, 8000), truncated: lsof.stdout.length > 8000 }, [
|
|
504
|
+
'lsof 的输出原样返回(截断到 8000 字符;truncated=true 表示后面还有没给出来的内容)',
|
|
505
|
+
'这是**一次快照**:不说明套接字的历史,也不说明谁在连它',
|
|
506
|
+
])
|
|
507
|
+
}
|
|
442
508
|
const listeners = requireOk(await runCmd(shell, ['netstat', '-an'], exec), 'netstat -an')
|
|
443
509
|
.split('\n').filter((l) => l.includes('LISTEN')).slice(0, 80)
|
|
444
|
-
if (listeners.length > 0)
|
|
445
|
-
|
|
510
|
+
if (listeners.length > 0) {
|
|
511
|
+
return envelope({ method: 'netstat', dump: listeners.join('\n'), truncated: true }, [
|
|
512
|
+
'lsof 没跑通,这份读数是 netstat -an 里含 LISTEN 的行(只取前 80 行)',
|
|
513
|
+
'netstat 的表格比 lsof 粗:没有进程名/PID,只说「有东西在这个地址上听」',
|
|
514
|
+
])
|
|
515
|
+
}
|
|
516
|
+
return envelope({ method: null, dump: '' }, [
|
|
517
|
+
'lsof 与 netstat 都没有给出 LISTEN 行 —— 这句话的前提是**两条命令都真的跑过了**;否则「没量到」会被读成「没有」',
|
|
518
|
+
])
|
|
446
519
|
},
|
|
447
520
|
}))
|
|
448
521
|
|
|
@@ -538,7 +611,7 @@ export function apply(ctx) {
|
|
|
538
611
|
: delta > 30
|
|
539
612
|
? `外网比网关多 ${delta.toFixed(1)}ms:这段差值来自光猫/路由器的排队与路径,跟 Wi-Fi 空口无关。`
|
|
540
613
|
: `外网与网关只差 ${delta.toFixed(1)}ms,路径干净。此时若仍感到卡,更可能在空口 —— 用 diag_wifi_link 看协商速率与抖动。`
|
|
541
|
-
|
|
614
|
+
const snapshot = {
|
|
542
615
|
gateway: { target: gateway, ...gw, line: pingLine(`网关 ${gateway}`, gw) },
|
|
543
616
|
wan: { target: wanTarget, ...wan, line: pingLine(`外网 ${wanTarget}`, wan) },
|
|
544
617
|
deltaMs: delta,
|
|
@@ -546,6 +619,20 @@ export function apply(ctx) {
|
|
|
546
619
|
jitterNote: gw.stddevMs !== null && gw.stddevMs > 2
|
|
547
620
|
? `网关抖动 ${gw.stddevMs}ms 偏大:空口在被抢,看 diag_wifi_channels 里有没有同频邻居。`
|
|
548
621
|
: null,
|
|
622
|
+
}
|
|
623
|
+
// 证据形状(规划 §5.1):信封 JSON —— 一次 RTT 采样(网关 + 外网)。
|
|
624
|
+
// limitations 必须说清"这几个数字各自是什么、以及它们看不见什么"。
|
|
625
|
+
return JSON.stringify({
|
|
626
|
+
tool: 'diag_net_rtt',
|
|
627
|
+
target: gateway + ' (gateway) + ' + wanTarget + ' (wan)',
|
|
628
|
+
operation: 'send ICMP echoes to both targets and report min/avg/max, loss and jitter',
|
|
629
|
+
observed: snapshot,
|
|
630
|
+
confidence: 'observed',
|
|
631
|
+
limitations: [
|
|
632
|
+
'回环 / 网关 / 外网是三个不同的东西:别把回环的 0.1ms 当成\u201c网好\u201d',
|
|
633
|
+
'ICMP 会被中间设备限速或直接丢弃 ⇒ 丢包不等于链路断(要用 TCP 层再证一次)',
|
|
634
|
+
'一次采样的 min/avg/max 不说明抖动趋势 —— 要趋势得用 diag_net_watch 那类记录仪',
|
|
635
|
+
],
|
|
549
636
|
}, null, 2)
|
|
550
637
|
},
|
|
551
638
|
}))
|
|
@@ -587,7 +674,7 @@ export function apply(ctx) {
|
|
|
587
674
|
}
|
|
588
675
|
const aggregateBps = measured.reduce((sum, sample) => sum + sample.bps, 0)
|
|
589
676
|
const totalBytes = samples.reduce((sum, sample) => sum + sample.bytes, 0)
|
|
590
|
-
|
|
677
|
+
const snapshot = {
|
|
591
678
|
url,
|
|
592
679
|
streams,
|
|
593
680
|
seconds,
|
|
@@ -597,6 +684,20 @@ export function apply(ctx) {
|
|
|
597
684
|
totalBytes,
|
|
598
685
|
totalMiB: Number((totalBytes / 1048576).toFixed(1)),
|
|
599
686
|
note: '默认按「跑满」配置(8 流 × 10 秒);totalBytes 是本次实际消耗的流量。测出来的是「无线链路 + 外网」合起来的上限,不是链路理论值。',
|
|
687
|
+
}
|
|
688
|
+
// 证据形状(规划 §5.1):信封 JSON。这个工具是七个里**唯一会产生真实流量**的,
|
|
689
|
+
// 所以 limitations 要讲清"这次花了你多少流量"与"速率读数看不见什么"。
|
|
690
|
+
return JSON.stringify({
|
|
691
|
+
tool: 'diag_net_throughput',
|
|
692
|
+
target: url,
|
|
693
|
+
operation: 'run ' + streams + ' parallel curl streams for up to ' + seconds + 's each and report throughput',
|
|
694
|
+
observed: snapshot,
|
|
695
|
+
confidence: 'observed',
|
|
696
|
+
limitations: [
|
|
697
|
+
'这是一次**真实流量**测量:observed.totalBytes 就是本次消耗的字节数(默认档位是 8 流 × 10 秒)',
|
|
698
|
+
'读的是链路**容量**:不说明时延、丢包或抖动 —— 那些用 diag_net_rtt / diag_net_watch 量',
|
|
699
|
+
'聚合速率受对端与服务端共同限制:它只证明"这条路径此刻能跑到多少",不区分是谁的限制',
|
|
700
|
+
],
|
|
600
701
|
}, null, 2)
|
|
601
702
|
},
|
|
602
703
|
}))
|
|
@@ -616,11 +717,21 @@ export function apply(ctx) {
|
|
|
616
717
|
},
|
|
617
718
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
618
719
|
async execute(args, exec) {
|
|
619
|
-
|
|
720
|
+
// undefined 必须保持 undefined(否则默认目录就没了):只规范化"给了且以 - 开头"的那种。
|
|
721
|
+
const paths = watchPaths(args.dir === undefined ? undefined : safePath(args.dir))
|
|
620
722
|
const action = String(args.action ?? 'status').trim() === '' ? 'status' : String(args.action).trim()
|
|
621
723
|
const gateway = requireHost(args.gateway, '192.168.1.1')
|
|
622
724
|
const wan = requireHost(args.wan, '223.5.5.5')
|
|
623
725
|
const count = clampInteger(args.count, 8, 4, 30)
|
|
726
|
+
// 证据形状(规划 §5.1):逐支迁移 —— 本轮只把 status 支包上(没有消费方读它的裸文本)。
|
|
727
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
728
|
+
tool: 'diag_net_watch',
|
|
729
|
+
target: paths.dir,
|
|
730
|
+
operation: 'long-running link/RTT recorder: ' + action,
|
|
731
|
+
observed,
|
|
732
|
+
confidence: 'observed',
|
|
733
|
+
limitations,
|
|
734
|
+
}, null, 2)
|
|
624
735
|
|
|
625
736
|
if (action === 'start') {
|
|
626
737
|
const interval = clampInteger(args.interval, 10, 3, 600)
|
|
@@ -635,7 +746,7 @@ export function apply(ctx) {
|
|
|
635
746
|
timeoutMs: 7 * 24 * 3600 * 1000,
|
|
636
747
|
signal: exec.signal,
|
|
637
748
|
}))
|
|
638
|
-
return [
|
|
749
|
+
return envelope({ method: 'start', log: paths.log, interval, linkEvery, count, gateway, wan, dump: [
|
|
639
750
|
`记录仪已启动`,
|
|
640
751
|
`日志:${paths.log}`,
|
|
641
752
|
`间隔 ${interval}s;每 ${linkEvery} 次采样取一次链路;每次每目标 ${count} 个包`,
|
|
@@ -644,7 +755,10 @@ export function apply(ctx) {
|
|
|
644
755
|
`卡住时立刻打标记:diag_net_watch action=mark message="电视卡了"`,
|
|
645
756
|
`停止:diag_net_watch action=stop`,
|
|
646
757
|
`回看:diag_net_watch action=report`,
|
|
647
|
-
].join('\n')
|
|
758
|
+
].join('\n') }, [
|
|
759
|
+
'这是**后台**记录仪:本工具返回的时候采样循环才刚开始 ⇒ 日志会一直长,随时用 action=report 回看',
|
|
760
|
+
'observed 里带着它的节奏(间隔 / 每 N 次取链路 / 每目标包数)—— 以后看报告时能对上它是按什么节奏记的',
|
|
761
|
+
])
|
|
648
762
|
}
|
|
649
763
|
|
|
650
764
|
if (action === 'stop') {
|
|
@@ -655,7 +769,10 @@ export function apply(ctx) {
|
|
|
655
769
|
{ timeoutMs: 20000 },
|
|
656
770
|
)
|
|
657
771
|
requireOk(run, '写停止文件')
|
|
658
|
-
return `停止信号已写入 ${paths.stop}\n采样循环会在当前这一轮 sleep 结束后退出,最多等一个采样间隔。用 action=status 确认。`
|
|
772
|
+
return envelope({ method: 'stop', stopFile: paths.stop, dump: `停止信号已写入 ${paths.stop}\n采样循环会在当前这一轮 sleep 结束后退出,最多等一个采样间隔。用 action=status 确认。` }, [
|
|
773
|
+
'这是**异步**停止:写完信号文件就返回了,采样循环要等当前这一轮 sleep 结束才退出',
|
|
774
|
+
'要确认它真的停了,用 action=status 看最后写入时间,或再 action=report 比样本数',
|
|
775
|
+
])
|
|
659
776
|
}
|
|
660
777
|
|
|
661
778
|
if (action === 'sample') {
|
|
@@ -666,7 +783,10 @@ export function apply(ctx) {
|
|
|
666
783
|
{ timeoutMs: 30000 },
|
|
667
784
|
)
|
|
668
785
|
requireOk(run, '采样')
|
|
669
|
-
return `已采样并追加到 ${paths.log}\n用 action=report 看,或 action=mark 标一下刚才发生了什么。`
|
|
786
|
+
return envelope({ method: 'sample', log: paths.log, dump: `已采样并追加到 ${paths.log}\n用 action=report 看,或 action=mark 标一下刚才发生了什么。` }, [
|
|
787
|
+
'这一次采样写进了日志;它的**内容**要看 action=report(这里只报"写成功了")',
|
|
788
|
+
'样本是往日志尾部**追加**的:这个动作不会覆盖历史,也不会改变记录仪是否在跑',
|
|
789
|
+
])
|
|
670
790
|
}
|
|
671
791
|
|
|
672
792
|
if (action === 'mark') {
|
|
@@ -683,7 +803,10 @@ export function apply(ctx) {
|
|
|
683
803
|
{ timeoutMs: 20000 },
|
|
684
804
|
)
|
|
685
805
|
requireOk(run, '写标记')
|
|
686
|
-
return `已标记「${message}」@ ${stampOf(Math.floor(Date.now() / 1000))}\n日志:${paths.log}`
|
|
806
|
+
return envelope({ method: 'mark', log: paths.log, dump: `已标记「${message}」@ ${stampOf(Math.floor(Date.now() / 1000))}\n日志:${paths.log}` }, [
|
|
807
|
+
'标记是你打的**锚点**,它本身不是测量 —— report 会把它对齐到最近的样本(误差 ≤ 一个采样间隔)',
|
|
808
|
+
'标记只往日志里追加一行,不改变采样节奏,也不影响记录仪是否在跑',
|
|
809
|
+
])
|
|
687
810
|
}
|
|
688
811
|
|
|
689
812
|
if (action === 'report') {
|
|
@@ -695,13 +818,27 @@ export function apply(ctx) {
|
|
|
695
818
|
{ timeoutMs: 30000, stdoutMaxBytes: 8388608 },
|
|
696
819
|
)
|
|
697
820
|
if (run.stdout.trim() === '') {
|
|
698
|
-
return
|
|
821
|
+
return envelope({ method: 'report', samples: 0, dump: '', log: paths.log }, [
|
|
822
|
+
'日志不存在或为空 ⇒ 这是「还没有样本」,不是「网络没问题」',
|
|
823
|
+
`先 action=start 起记录仪,或 action=sample 取一个(看的是 ${paths.log})`,
|
|
824
|
+
])
|
|
699
825
|
}
|
|
700
826
|
const { rows, marks, skipped } = readWatch(run.stdout)
|
|
701
827
|
if (rows.length === 0) {
|
|
702
|
-
return
|
|
703
|
-
|
|
704
|
-
:
|
|
828
|
+
return envelope({
|
|
829
|
+
method: 'report',
|
|
830
|
+
samples: 0,
|
|
831
|
+
skipped,
|
|
832
|
+
log: paths.log,
|
|
833
|
+
dump: skipped > 0
|
|
834
|
+
? `日志里有 ${skipped} 个样本还没写完(记录仪正在写),暂时没有完整的样本可看。稍后再试。`
|
|
835
|
+
: `日志里没解析出完整样本(${paths.log}):文件可能刚被截断。`,
|
|
836
|
+
}, [
|
|
837
|
+
'一个完整样本都没有 ⇒ 这份报告里**没有任何测量**,不是「这一段时间网络正常」',
|
|
838
|
+
skipped > 0
|
|
839
|
+
? '只有半截样本 —— 读到一半的记录会得出凭空的故障结论,所以它不算证据'
|
|
840
|
+
: '文件可能刚被截断,也可能记录仪根本没在跑 —— 这两种要分开查',
|
|
841
|
+
])
|
|
705
842
|
}
|
|
706
843
|
const first = rows[0]
|
|
707
844
|
const last = rows[rows.length - 1]
|
|
@@ -756,7 +893,13 @@ export function apply(ctx) {
|
|
|
756
893
|
+ ` 外网 ${nearest.wan.avgMs ?? '无回应'}ms(抖动 ${nearest.wan.stddevMs ?? '—'}ms)`
|
|
757
894
|
+ ` 链路 ${nearest.link.channel ?? '—'}/${nearest.link.widthMhz ?? '—'}MHz 协商 ${nearest.link.transmitRateMbps ?? '—'}Mbps`)
|
|
758
895
|
}
|
|
759
|
-
|
|
896
|
+
// 报告文本原样进 observed.dump(消费方都是子串正则,不需要改读法)。
|
|
897
|
+
return envelope({ method: 'report', samples: rows.length, skipped, dump: lines.join('\n') }, [
|
|
898
|
+
'报告覆盖的是**日志里记着的那段时间** —— 记录仪没起着的时段,它什么都不知道',
|
|
899
|
+
skipped > 0
|
|
900
|
+
? '有未写完的样本被跳过(数量见 observed.skipped):那些时刻只有半个样本,不能当证据'
|
|
901
|
+
: '这次没有跳过未写完的样本;但"没跳过"只说明这次读的时候没有半截样本',
|
|
902
|
+
])
|
|
760
903
|
}
|
|
761
904
|
|
|
762
905
|
// status(默认):永远给出可读结论,绝不返回空字符串。
|
|
@@ -784,7 +927,13 @@ export function apply(ctx) {
|
|
|
784
927
|
' echo "还没有日志文件"',
|
|
785
928
|
'fi',
|
|
786
929
|
].join('\n')], exec, { timeoutMs: 20000, stdoutMaxBytes: 262144 })
|
|
787
|
-
|
|
930
|
+
const statusText = run.stdout.trim()
|
|
931
|
+
// 「未知」与「没在跑」是两件事:状态命令没输出时不许说成没在跑。
|
|
932
|
+
return envelope({ method: 'status', dump: statusText, known: statusText !== '' }, [
|
|
933
|
+
statusText === ''
|
|
934
|
+
? '状态命令没有输出 ⇒ 这是「未知」,不是「没在跑」'
|
|
935
|
+
: '这是记录仪自己报的状态,不是从日志推断的;它只说这一刻,不说明采样准不准',
|
|
936
|
+
])
|
|
788
937
|
},
|
|
789
938
|
}))
|
|
790
939
|
|
|
@@ -18,7 +18,28 @@ function shq(value) {
|
|
|
18
18
|
* `TypeError: shell.run is not a function` on every call, in every engine
|
|
19
19
|
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
20
20
|
* names a seam method the executor does not have.
|
|
21
|
+
*
|
|
22
|
+
* **失败面的口径是"大声"**(Q5):
|
|
23
|
+
* · 没有 `checksec`(macOS 就是)⇒ 走**仓库内置的原生解析器**算 PIE/NX/RELRO/canary,并在
|
|
24
|
+
* `limitations` 里明说"这是内置解析器算的";**绝不**再出现"缺工具 ⇒ 静默回退 ⇒ 对所有文件
|
|
25
|
+
* 都报 pie=no"那种事故(2026-10-01 的原始缺陷正是它);
|
|
26
|
+
* · `pwn_cyclic` 的长度会被夹到 1..4096 —— 夹取是**读数**(`observed.clamped`),不是沉默;
|
|
27
|
+
* · `pwn_rop_hint` **不看文件**:它的清单用 `confidence: 'inferred'` 标出来,并在 `limitations`
|
|
28
|
+
* 里写清"前提必须先由 pwn_checksec / rev_* / bin_* 验过"。
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-13)。
|
|
32
|
+
*
|
|
33
|
+
* 为什么不插 `--`:实测本机(macOS,发布目标平台)**objdump(-h/-d/-T)/ base64 -i / otool -hv 都不支持
|
|
34
|
+
* `--`**,插进去直接失败;而 `file -b --version` 会打印 `file-5.41`、`nm -u --version` 会打印
|
|
35
|
+
* `llvm-nm, …`,两者都 **exit 0** ⇒ 工具会把版本串当**数据**返回。加 `./` 之后它就是一个普通相对路径:
|
|
36
|
+
* 不存在就正常报错,存在就是那个文件。
|
|
21
37
|
*/
|
|
38
|
+
function safePath(value) {
|
|
39
|
+
const text = String(value ?? '')
|
|
40
|
+
return text.startsWith('-') ? './' + text : text
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
23
44
|
const result = await (await shell.execute(shell.resolve({
|
|
24
45
|
command: argv.map(shq).join(' '),
|
|
@@ -50,9 +71,19 @@ async function runCmd(shell, argv, exec, opts = {}) {
|
|
|
50
71
|
*/
|
|
51
72
|
function requireOk(run, what) {
|
|
52
73
|
if (run.exitCode === 0) return run.stdout
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
// 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
|
|
75
|
+
// 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
|
|
76
|
+
// 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
|
|
77
|
+
const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
|
|
78
|
+
const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
|
|
79
|
+
const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
|
|
80
|
+
const missing = notInstalled ? ' — the command is not installed on this host' : ''
|
|
81
|
+
// 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
|
|
82
|
+
// 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
|
|
83
|
+
const next = notInstalled
|
|
84
|
+
? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
|
|
85
|
+
: 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
|
|
86
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
|
|
56
87
|
error.name = 'CommandError'
|
|
57
88
|
throw error
|
|
58
89
|
}
|
|
@@ -534,9 +565,35 @@ export function apply(ctx) {
|
|
|
534
565
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
535
566
|
},
|
|
536
567
|
async execute(args, exec) {
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
568
|
+
const path = safePath(args.path)
|
|
569
|
+
const ck = await runCmd(shell, ['checksec', '--file=' + path], exec)
|
|
570
|
+
if (ck.exitCode === 0) {
|
|
571
|
+
return JSON.stringify({
|
|
572
|
+
tool: 'pwn_checksec',
|
|
573
|
+
target: args.path,
|
|
574
|
+
operation: 'read mitigations with the installed checksec',
|
|
575
|
+
observed: { method: 'checksec', dump: ck.stdout },
|
|
576
|
+
confidence: 'observed',
|
|
577
|
+
limitations: [
|
|
578
|
+
'checksec 的输出原样返回(本工具不改写它)',
|
|
579
|
+
'checksec 的判断基于它自己支持的格式/段:**没提到的项不代表不存在**(要逐条核就用 bin_readelf)',
|
|
580
|
+
],
|
|
581
|
+
}, null, 2)
|
|
582
|
+
}
|
|
583
|
+
// 本机没有 checksec(macOS 就是):这份读数来自仓库内置的原生解析器。
|
|
584
|
+
const nativeLines = (await mitigationLines(shell, path, exec)).join('\n')
|
|
585
|
+
return JSON.stringify({
|
|
586
|
+
tool: 'pwn_checksec',
|
|
587
|
+
target: args.path,
|
|
588
|
+
operation: 'compute mitigations from the file itself (no checksec on this host)',
|
|
589
|
+
observed: { method: 'native', dump: nativeLines },
|
|
590
|
+
confidence: 'observed',
|
|
591
|
+
limitations: [
|
|
592
|
+
'**本机没有 checksec** ⇒ 这份读数是**仓库内置解析器**算的,不是 checksec 的输出',
|
|
593
|
+
'内置解析器只覆盖本工具声明的字段(arch/pie/nx/relro/canary);checksec 的其它判断(如 fortify)它不做',
|
|
594
|
+
'它对 Mach-O 与 ELF 各有一套路子:看 method 与 dump 里的措辞,别把 macho 的 relro=n/a 读成"没开"',
|
|
595
|
+
],
|
|
596
|
+
}, null, 2)
|
|
540
597
|
},
|
|
541
598
|
}))
|
|
542
599
|
|
|
@@ -552,7 +609,19 @@ export function apply(ctx) {
|
|
|
552
609
|
},
|
|
553
610
|
async execute(args) {
|
|
554
611
|
const n = Math.max(1, Math.min(4096, Number(args.length) || 64))
|
|
555
|
-
|
|
612
|
+
// 证据形状(规划 §5.1):信封 JSON(模式本身进 observed,不塞进 dump 的文本里)。
|
|
613
|
+
return JSON.stringify({
|
|
614
|
+
tool: 'pwn_cyclic',
|
|
615
|
+
target: 'de Bruijn pattern',
|
|
616
|
+
operation: 'generate a cyclic pattern for offset discovery (pwntools-compatible alphabet)',
|
|
617
|
+
observed: { length: n, clamped: n !== (Number(args.length) || 64), dump: cyclic(n) },
|
|
618
|
+
confidence: 'observed',
|
|
619
|
+
limitations: [
|
|
620
|
+
'模式只帮你**定位**偏移:它不告诉你偏移在哪,也不说明那条路径可达(配合真实崩溃日志/调试器才有意义)',
|
|
621
|
+
'长度会被夹到 1..4096:传大了会**静默变短** ⇒ 用 observed.length(或 clamped=true)核对实际给的位数',
|
|
622
|
+
'字母表与 pwntools 的 cyclic 一致,但**只保证前缀相同**:拿别处的 offset 表来查时,先确认两边是同一套字母表',
|
|
623
|
+
],
|
|
624
|
+
}, null, 2)
|
|
556
625
|
},
|
|
557
626
|
}))
|
|
558
627
|
|
|
@@ -568,7 +637,12 @@ export function apply(ctx) {
|
|
|
568
637
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
569
638
|
},
|
|
570
639
|
async execute(args) {
|
|
571
|
-
|
|
640
|
+
// 证据形状(规划 §5.1):清单型工具 —— 清单本身就是它给出的东西(进 dump)。
|
|
641
|
+
return JSON.stringify({
|
|
642
|
+
tool: 'pwn_rop_hint',
|
|
643
|
+
target: args.binary,
|
|
644
|
+
operation: 'produce a ret2libc/ROP planning checklist (does not read the binary at all)',
|
|
645
|
+
observed: { binary: args.binary, goal: args.goal ?? 'shell', dump: [
|
|
572
646
|
`# ROP plan for ${args.binary}`,
|
|
573
647
|
`goal: ${args.goal ?? 'shell'}`,
|
|
574
648
|
'1. confirm overflow offset via cyclic + crash RIP',
|
|
@@ -577,7 +651,15 @@ export function apply(ctx) {
|
|
|
577
651
|
'4. second payload: pop rdi; ret → binsh → system (align stack with extra ret)',
|
|
578
652
|
'5. prefer local lab targets only; never against unauthorized hosts',
|
|
579
653
|
'note: this tool is a planning checklist only — it does not inspect the binary; pair it with pwn_checksec, rev_imports and rev_gadget_scan',
|
|
580
|
-
|
|
654
|
+
].join('\n') },
|
|
655
|
+
confidence: 'inferred',
|
|
656
|
+
limitations: [
|
|
657
|
+
'**这是清单,不是结论**:本工具**根本不看文件** —— 不读 ELF、不找 gadget、不算偏移',
|
|
658
|
+
'所以它的前提(PIE/NX/canary/RELRO、溢出偏移、libc 版本)必须先由 pwn_checksec / rev_* / bin_* 验过;没验过就不能按它动手',
|
|
659
|
+
'步骤顺序是**一种**常见打法(ret2libc),不是唯一解:目标有 seccomp、特殊 libc 或栈保护时整条路可能不成立',
|
|
660
|
+
'只用在与授权范围一致的目标上(本地 lab 优先)',
|
|
661
|
+
],
|
|
662
|
+
}, null, 2)
|
|
581
663
|
},
|
|
582
664
|
}))
|
|
583
665
|
}
|