oh-my-knowledge 0.35.0 → 0.37.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.
Files changed (90) hide show
  1. package/README.md +5 -3
  2. package/README.zh.md +5 -3
  3. package/dist/assets/agent-skills/omk/SKILL.md +4 -2
  4. package/dist/assets/agent-skills/omk/references/commands.md +121 -2
  5. package/dist/authoring/evolver.js +1 -1
  6. package/dist/cli/commands/doctor.js +21 -5
  7. package/dist/cli/commands/eval/index.js +7 -6
  8. package/dist/cli/commands/evolve.d.ts +11 -0
  9. package/dist/cli/commands/evolve.js +105 -5
  10. package/dist/cli/commands/list.d.ts +25 -0
  11. package/dist/cli/commands/list.js +118 -0
  12. package/dist/cli/commands/promote.d.ts +24 -0
  13. package/dist/cli/commands/promote.js +158 -0
  14. package/dist/cli/commands/rollback.d.ts +20 -0
  15. package/dist/cli/commands/rollback.js +95 -0
  16. package/dist/cli/commands/sample.d.ts +1 -0
  17. package/dist/cli/commands/sample.js +10 -4
  18. package/dist/cli/lib/cell-format.d.ts +17 -0
  19. package/dist/cli/lib/cell-format.js +19 -0
  20. package/dist/cli/lib/cmd-flags.d.ts +1 -0
  21. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  22. package/dist/cli/lib/i18n-dict/common.js +8 -0
  23. package/dist/cli/lib/i18n-dict/list.d.ts +3 -0
  24. package/dist/cli/lib/i18n-dict/list.js +36 -0
  25. package/dist/cli/lib/i18n-dict/promote.d.ts +3 -0
  26. package/dist/cli/lib/i18n-dict/promote.js +54 -0
  27. package/dist/cli/lib/i18n-dict/rollback.d.ts +3 -0
  28. package/dist/cli/lib/i18n-dict/rollback.js +22 -0
  29. package/dist/cli/lib/i18n-dict.d.ts +4 -1
  30. package/dist/cli/lib/i18n-dict.js +6 -0
  31. package/dist/cli/lib/progress.d.ts +3 -0
  32. package/dist/cli/lib/progress.js +22 -0
  33. package/dist/cli/lib/run-tally.js +1 -1
  34. package/dist/cli/lib/shared.js +1 -1
  35. package/dist/cli/lib/source-probe.d.ts +15 -0
  36. package/dist/cli/lib/source-probe.js +129 -0
  37. package/dist/doctor/endpoint-rule.d.ts +60 -0
  38. package/dist/doctor/endpoint-rule.js +381 -0
  39. package/dist/doctor/health/load-custom-dimensions.d.ts +6 -0
  40. package/dist/doctor/health/load-custom-dimensions.js +43 -3
  41. package/dist/doctor/index.js +9 -2
  42. package/dist/eval-core/evaluation-reporting.d.ts +1 -0
  43. package/dist/eval-core/evaluation-reporting.js +6 -4
  44. package/dist/eval-workflows/batch-evaluation-workflow.js +3 -2
  45. package/dist/eval-workflows/run-evaluation.js +2 -2
  46. package/dist/executors/codex-cli.js +5 -1
  47. package/dist/inputs/skill-loader.js +6 -3
  48. package/dist/managed/index.d.ts +2 -0
  49. package/dist/managed/index.js +2 -0
  50. package/dist/managed/list-view.d.ts +58 -0
  51. package/dist/managed/list-view.js +70 -0
  52. package/dist/managed/promote-gate.d.ts +40 -0
  53. package/dist/managed/promote-gate.js +37 -0
  54. package/dist/managed/store.d.ts +25 -2
  55. package/dist/managed/store.js +137 -11
  56. package/dist/observability/experience.d.ts +2 -0
  57. package/dist/observability/experience.js +31 -2
  58. package/dist/observability/inbox.js +31 -5
  59. package/dist/observability/review-state.js +22 -11
  60. package/dist/observability/soft-standards/llm-extractor.js +3 -3
  61. package/dist/observability/soft-standards/skill-standards-store.d.ts +1 -0
  62. package/dist/observability/soft-standards/skill-standards-store.js +77 -14
  63. package/dist/observability/soft-standards/types.d.ts +2 -2
  64. package/dist/renderer/doctor-detail-renderer.d.ts +9 -0
  65. package/dist/renderer/doctor-detail-renderer.js +114 -0
  66. package/dist/renderer/html-renderer.d.ts +3 -2
  67. package/dist/renderer/html-renderer.js +102 -102
  68. package/dist/renderer/icons.d.ts +29 -0
  69. package/dist/renderer/icons.js +66 -0
  70. package/dist/renderer/layout.js +81 -68
  71. package/dist/renderer/observation-inbox/styles.d.ts +1 -1
  72. package/dist/renderer/observation-inbox/styles.js +53 -53
  73. package/dist/renderer/report-shell.d.ts +77 -0
  74. package/dist/renderer/report-shell.js +223 -0
  75. package/dist/renderer/skill-detail-renderer.js +147 -352
  76. package/dist/renderer/skill-health-renderer.js +50 -73
  77. package/dist/renderer/skill-list-renderer.js +358 -306
  78. package/dist/renderer/summary.js +272 -162
  79. package/dist/renderer/test-view.d.ts +4 -3
  80. package/dist/renderer/test-view.js +386 -135
  81. package/dist/server/report-server.js +142 -28
  82. package/dist/server/report-store.d.ts +1 -1
  83. package/dist/server/report-store.js +16 -15
  84. package/dist/server/skill-index.js +5 -4
  85. package/dist/types/doctor.d.ts +24 -2
  86. package/dist/types/doctor.js +1 -1
  87. package/dist/types/managed.d.ts +17 -3
  88. package/dist/types/observability.d.ts +6 -6
  89. package/dist/types/report.d.ts +9 -4
  90. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ export const promoteDict = {
2
+ 'cli.promote.kind_unsupported': {
3
+ zh: 'promote 暂仅支持 kind=skill;收到 {kind}。',
4
+ en: 'promote supports only kind=skill today; got {kind}.',
5
+ },
6
+ 'cli.promote.not_managed': {
7
+ zh: '未找到受管记录 {name}(kind={kind})。先用 omk install 纳管、omk eval 取证,再 promote。',
8
+ en: 'No managed record {name} (kind={kind}). Run omk install to register, omk eval to gather evidence, then promote.',
9
+ },
10
+ 'cli.promote.blocked_header': {
11
+ zh: 'promote 被门禁拦下({name}):',
12
+ en: 'promote was blocked by the gate ({name}):',
13
+ },
14
+ 'cli.promote.drifted': {
15
+ zh: '· 源已漂移或不可达:当前源内容与受管基线不一致,证据对应的是旧内容。重跑 omk eval 重新取证。',
16
+ en: '· source drifted or unreachable: current source content differs from the managed baseline; the evidence is for old content. Re-run omk eval.',
17
+ },
18
+ 'cli.promote.no_current_evidence': {
19
+ zh: '· 当前内容没有评测证据,无法 promote(force 也不行)。先跑 omk eval 取证(omk list 看证据状态)。',
20
+ en: '· no evaluation evidence for the current content; cannot promote (not even with --force). Run omk eval first (omk list shows evidence status).',
21
+ },
22
+ 'cli.promote.incomparable': {
23
+ zh: '· 证据的评委提示词指纹 {judgePromptHash} 不是当前评委:judge prompt 已变、旧 verdict 不可比。重跑 omk eval。',
24
+ en: '· evidence judge-prompt fingerprint {judgePromptHash} is not the current judge: the judge prompt changed, the old verdict is incomparable. Re-run omk eval.',
25
+ },
26
+ 'cli.promote.incomparable_unverified': {
27
+ zh: '⚠️ 证据缺评委提示词指纹,无法核对可比性(按未核处理,不拦门禁)。',
28
+ en: '⚠️ evidence has no judge-prompt fingerprint; comparability cannot be verified (treated as unchecked, not blocking).',
29
+ },
30
+ 'cli.promote.verdict_blocked': {
31
+ zh: '· 当前证据 verdict={verdict},不在接受集(默认仅 PROGRESS;CAUTIOUS 需 --accept-cautious)。',
32
+ en: '· current evidence verdict={verdict} is not in the accepted set (default PROGRESS only; CAUTIOUS needs --accept-cautious).',
33
+ },
34
+ 'cli.promote.force_hint': {
35
+ zh: '如确需越门,加 --force 并用 --reason 写明理由(会记为人工 override 决定)。',
36
+ en: 'To override anyway, pass --force with --reason (recorded as a human override decision).',
37
+ },
38
+ 'cli.promote.force_reason_required': {
39
+ zh: '--force 必须同时提供非空 --reason,说明人工越门理由。',
40
+ en: '--force requires a non-empty --reason explaining the human override.',
41
+ },
42
+ 'cli.promote.promoted': {
43
+ zh: '已 promote {name}:接受内容 {hash}(verdict={verdict},report={reportId})。',
44
+ en: 'Promoted {name}: accepted content {hash} (verdict={verdict}, report={reportId}).',
45
+ },
46
+ 'cli.promote.forced_override': {
47
+ zh: '⚠️ 已 --force 越门 promote {name}:接受内容 {hash}(被越过的 verdict={verdict})。越门已记为人工决定。',
48
+ en: '⚠️ force-promoted {name}: accepted content {hash} (overridden verdict={verdict}). The override is recorded as a human decision.',
49
+ },
50
+ 'cli.promote.already_promoted': {
51
+ zh: '{name} 的当前内容已 promote 过,无需重复(幂等,未改动记录)。',
52
+ en: '{name} current content is already promoted; nothing to do (idempotent, record untouched).',
53
+ },
54
+ };
@@ -0,0 +1,3 @@
1
+ import type { CliMessage } from './types.js';
2
+ export type RollbackMessageKey = 'cli.rollback.kind_unsupported' | 'cli.rollback.not_managed' | 'cli.rollback.not_promoted' | 'cli.rollback.rolled_back' | 'cli.rollback.already_rolled_back';
3
+ export declare const rollbackDict: Record<RollbackMessageKey, CliMessage>;
@@ -0,0 +1,22 @@
1
+ export const rollbackDict = {
2
+ 'cli.rollback.kind_unsupported': {
3
+ zh: 'rollback 暂仅支持 kind=skill;收到 {kind}。',
4
+ en: 'rollback supports only kind=skill today; got {kind}.',
5
+ },
6
+ 'cli.rollback.not_managed': {
7
+ zh: '未找到受管记录 {name}(kind={kind})。omk rollback 只作用于已 install 的 skill。',
8
+ en: 'No managed record {name} (kind={kind}). omk rollback only operates on installed skills.',
9
+ },
10
+ 'cli.rollback.not_promoted': {
11
+ zh: '{name} 的当前内容未处于 promoted 态,无可回退(omk list 看生命周期状态)。',
12
+ en: '{name} current content is not promoted; nothing to roll back (omk list shows the lifecycle state).',
13
+ },
14
+ 'cli.rollback.rolled_back': {
15
+ zh: '已回退 {name}:撤销当前内容 {hash} 的 promoted 接受(已记为人工决定)。源仍匹配受管基线则 omk list 回到 measurable,源已漂移则仍为 stale。',
16
+ en: 'Rolled back {name}: revoked the promoted acceptance of current content {hash} (recorded as a human decision). omk list shows measurable if the source still matches the managed baseline, or stale if it has drifted.',
17
+ },
18
+ 'cli.rollback.already_rolled_back': {
19
+ zh: '{name} 的当前内容本就未 promoted,无需回退(幂等,未改动记录)。',
20
+ en: '{name} current content is already not promoted; nothing to do (idempotent, record untouched).',
21
+ },
22
+ };
@@ -56,8 +56,11 @@ import { type GenMessageKey } from './i18n-dict/gen.js';
56
56
  import { type HelpMessageKey } from './i18n-dict/help.js';
57
57
  import { type InitMessageKey } from './i18n-dict/init.js';
58
58
  import { type InstallMessageKey } from './i18n-dict/install.js';
59
+ import { type ListMessageKey } from './i18n-dict/list.js';
60
+ import { type PromoteMessageKey } from './i18n-dict/promote.js';
61
+ import { type RollbackMessageKey } from './i18n-dict/rollback.js';
59
62
  import { type RunMessageKey } from './i18n-dict/run.js';
60
63
  import type { CliMessage } from './i18n-dict/types.js';
61
64
  export type { CliMessage } from './i18n-dict/types.js';
62
- export type CliMessageKey = CommonMessageKey | EvolveMessageKey | GenMessageKey | HelpMessageKey | InitMessageKey | InstallMessageKey | RunMessageKey;
65
+ export type CliMessageKey = CommonMessageKey | EvolveMessageKey | GenMessageKey | HelpMessageKey | InitMessageKey | InstallMessageKey | ListMessageKey | PromoteMessageKey | RollbackMessageKey | RunMessageKey;
63
66
  export declare const CLI_DICT: Record<CliMessageKey, CliMessage>;
@@ -56,6 +56,9 @@ import { genDict } from './i18n-dict/gen.js';
56
56
  import { helpDict } from './i18n-dict/help.js';
57
57
  import { initDict } from './i18n-dict/init.js';
58
58
  import { installDict } from './i18n-dict/install.js';
59
+ import { listDict } from './i18n-dict/list.js';
60
+ import { promoteDict } from './i18n-dict/promote.js';
61
+ import { rollbackDict } from './i18n-dict/rollback.js';
59
62
  import { runDict } from './i18n-dict/run.js';
60
63
  export const CLI_DICT = {
61
64
  ...commonDict,
@@ -64,5 +67,8 @@ export const CLI_DICT = {
64
67
  ...helpDict,
65
68
  ...initDict,
66
69
  ...installDict,
70
+ ...listDict,
71
+ ...promoteDict,
72
+ ...rollbackDict,
67
73
  ...runDict,
68
74
  };
@@ -1,4 +1,5 @@
1
1
  import { type CliLang } from './i18n.js';
2
+ import type { DoctorProgressInfo } from '../../types/doctor.js';
2
3
  export interface ProgressInfo {
3
4
  phase: string;
4
5
  completed?: number;
@@ -22,3 +23,5 @@ export interface ProgressInfo {
22
23
  error?: string;
23
24
  }
24
25
  export declare function makeOnProgress(lang: CliLang): (info: ProgressInfo) => void;
26
+ /** doctor 批量体检进度打印(per-skill,写 stderr),对齐 eval 的 makeOnProgress。 */
27
+ export declare function makeDoctorProgress(lang: CliLang): (info: DoctorProgressInfo) => void;
@@ -59,3 +59,25 @@ export function makeOnProgress(lang) {
59
59
  }
60
60
  };
61
61
  }
62
+ // 图标 + 状态枚举(pass/warn/fail 是技术枚举,与图标一样语言无关,不进 i18n 模板)。
63
+ const DOCTOR_STATUS_LABEL = {
64
+ pass: '✓ pass',
65
+ warn: '⚠ warn',
66
+ fail: '✗ fail',
67
+ };
68
+ /** doctor 批量体检进度打印(per-skill,写 stderr),对齐 eval 的 makeOnProgress。 */
69
+ export function makeDoctorProgress(lang) {
70
+ return (info) => {
71
+ // 单 skill 时省略 [i/n] 前缀(冗余);批量才显示计数。
72
+ const prefix = info.total > 1 ? `[${info.index}/${info.total}] ` : '';
73
+ if (info.phase === 'skill_start') {
74
+ process.stderr.write(tCli('cli.doctor.progress_skill_start', lang, { prefix, skill: info.skillName }));
75
+ }
76
+ else {
77
+ const result = DOCTOR_STATUS_LABEL[info.status ?? 'pass'] ?? DOCTOR_STATUS_LABEL.pass;
78
+ process.stderr.write(tCli('cli.doctor.progress_skill_done', lang, {
79
+ prefix, skill: info.skillName, result, ms: info.durationMs ?? 0,
80
+ }));
81
+ }
82
+ };
83
+ }
@@ -19,7 +19,7 @@ export function computeRunTally(report) {
19
19
  failed += s.errorCount;
20
20
  }
21
21
  };
22
- if (report.reportKind === 'batch-evaluation') {
22
+ if (report.kind === 'batch-evaluation') {
23
23
  for (const item of report.items)
24
24
  accumulate(item.summary);
25
25
  }
@@ -5,7 +5,7 @@ export function requireEvaluationReport(report, id, lang) {
5
5
  console.error(tCli('cli.common.report_not_found', lang, { id }));
6
6
  throw new CliExit(1);
7
7
  }
8
- if (report.reportKind === 'batch-evaluation') {
8
+ if (report.kind === 'batch-evaluation') {
9
9
  console.error(lang === 'zh'
10
10
  ? `报告 ${id} 是 BatchEvaluationReport。该命令需要单次 EvaluationReport;请使用其中的 child reportId。`
11
11
  : `Report ${id} is a BatchEvaluationReport. This command requires an EvaluationReport; use a child reportId from the batch.`);
@@ -0,0 +1,15 @@
1
+ import { type SourceProbe } from '../../managed/index.js';
2
+ import type { ManagedArtifactRecord } from '../../types/index.js';
3
+ /**
4
+ * 探测一条受管记录**当前源**的状态(三态,喂 buildManagedListRow 判 drift / 生命周期)。
5
+ * - 远端 git(带 url):源身份钉不可变 SHA(install 时 pin),内容恒定 → 直接取 record.contentHash,
6
+ * 不联网(快读命令不为 drift 检查发网络请求)。reachable。
7
+ * - 本地 git:locator `git:<ref>:<spec>` 复用 resolveInstallSource 在**仓库对象库**内重物化重哈
8
+ * (读取受 git 边界约束,无任意文件读 DoS)。解析不到(常因 cwd 与 install 时不同、spec 随 cwd 漂)
9
+ * → 抛错 → **reachable:false(未核,不当 stale)**,避免对未改动的 skill 误报漂移。
10
+ * - 本地 file:locator 是绝对路径,但受管 JSON **用户可手改 / 随仓库分发**(无 install、无 opt-in 即被
11
+ * loadAllManagedRecords 读到)。只读命令绝不盲读任意路径:**拒软链、只读常规文件 / 真目录、单文件
12
+ * 设 size cap**(挡 `evil.md → /dev/zero` 这类 readFileSync 无界 DoS 与项目外任意读)。守卫不过 →
13
+ * reachable:false。目录-skill 整树哈本就跳软链(hashArtifactSource 用 isFile() 过滤)。
14
+ */
15
+ export declare function probeSourceState(record: ManagedArtifactRecord): SourceProbe;
@@ -0,0 +1,129 @@
1
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
2
+ import { isAbsolute, resolve, join } from 'node:path';
3
+ import { resolveInstallSource } from '../../inputs/source-resolver.js';
4
+ import { hashArtifactSource, isDistributablePath } from '../../managed/index.js';
5
+ /**
6
+ * 受管记录**当前源**的状态探测——`omk list`(drift / 生命周期)与 `omk promote`(门禁前先确认源未漂)共用
7
+ * 的单一来源。两命令必须用同一套守卫与 drift 判定:否则 list 显示「未漂」而 promote 判「漂」(或反之)会
8
+ * 互相矛盾,且安全守卫一旦各写一份必然漂掉一份。
9
+ *
10
+ * 本地源读取成本上限:skill 是小体量 markdown(+少量 references 资产),远超这些边界的源必是手改 / 投毒,
11
+ * 拒读避免只读命令(读盘上可能随仓库分发的 locator)被 /dev/zero / 超大文件 / 巨型目录递归拖垮(DoS)。
12
+ */
13
+ const MAX_FILE_SOURCE_BYTES = 8 * 1024 * 1024; // 单文件-skill 上限
14
+ const MAX_DIR_SOURCE_BYTES = 64 * 1024 * 1024; // 目录-skill 整树累计上限
15
+ const MAX_DIR_SOURCE_FILES = 4000; // 目录-skill 文件数上限
16
+ const MAX_DIR_SOURCE_DEPTH = 64; // 目录递归深度上限
17
+ /**
18
+ * 目录-skill 源的**有边界**整树哈:先恢复 resolveFileSource 的形态校验(SKILL.md 存在、是常规文件、非
19
+ * 软链 —— 否则 locator 可指向任意可读目录让递归读整棵树),再 stat-walk(只 stat 不读)按与
20
+ * hashArtifactSource 同一 `isDistributablePath` 过滤累计 文件数 / 字节 / 深度,超界即返回 null(unreachable),
21
+ * 把单文件 DoS 不再换成目录递归 DoS。通过后才真正 hashArtifactSource。返回 null = 拒读 / 非法 skill 目录。
22
+ */
23
+ function boundedDirSkillHash(abs) {
24
+ const skillMd = join(abs, 'SKILL.md');
25
+ let md;
26
+ try {
27
+ md = lstatSync(skillMd);
28
+ }
29
+ catch {
30
+ return null; // 无 SKILL.md → 不是 skill 目录,拒
31
+ }
32
+ if (md.isSymbolicLink() || !md.isFile() || md.nlink !== 1)
33
+ return null; // SKILL.md 必须是常规、非软链、非硬链
34
+ let files = 0;
35
+ let bytes = 0;
36
+ const within = (dir, segs, depth) => {
37
+ if (depth > MAX_DIR_SOURCE_DEPTH)
38
+ return false;
39
+ let entries;
40
+ try {
41
+ entries = readdirSync(dir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ for (const e of entries) {
47
+ const ns = [...segs, e.name];
48
+ if (!isDistributablePath(ns))
49
+ continue; // 与 hashArtifactSource 读取范围一致
50
+ if (e.isDirectory()) {
51
+ if (!within(join(dir, e.name), ns, depth + 1))
52
+ return false;
53
+ }
54
+ else if (e.isFile()) { // 软链 Dirent 既非 isFile 也非 isDirectory → 天然跳过
55
+ if (++files > MAX_DIR_SOURCE_FILES)
56
+ return false;
57
+ try {
58
+ const fst = lstatSync(join(dir, e.name));
59
+ if (fst.nlink !== 1)
60
+ return false; // 硬链(nlink>1)可在树内别名树外敏感 inode → 拒读整树
61
+ bytes += fst.size;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ if (bytes > MAX_DIR_SOURCE_BYTES)
67
+ return false;
68
+ }
69
+ }
70
+ return true;
71
+ };
72
+ if (!within(abs, [], 0))
73
+ return null;
74
+ return hashArtifactSource(abs, true);
75
+ }
76
+ /**
77
+ * 探测一条受管记录**当前源**的状态(三态,喂 buildManagedListRow 判 drift / 生命周期)。
78
+ * - 远端 git(带 url):源身份钉不可变 SHA(install 时 pin),内容恒定 → 直接取 record.contentHash,
79
+ * 不联网(快读命令不为 drift 检查发网络请求)。reachable。
80
+ * - 本地 git:locator `git:<ref>:<spec>` 复用 resolveInstallSource 在**仓库对象库**内重物化重哈
81
+ * (读取受 git 边界约束,无任意文件读 DoS)。解析不到(常因 cwd 与 install 时不同、spec 随 cwd 漂)
82
+ * → 抛错 → **reachable:false(未核,不当 stale)**,避免对未改动的 skill 误报漂移。
83
+ * - 本地 file:locator 是绝对路径,但受管 JSON **用户可手改 / 随仓库分发**(无 install、无 opt-in 即被
84
+ * loadAllManagedRecords 读到)。只读命令绝不盲读任意路径:**拒软链、只读常规文件 / 真目录、单文件
85
+ * 设 size cap**(挡 `evil.md → /dev/zero` 这类 readFileSync 无界 DoS 与项目外任意读)。守卫不过 →
86
+ * reachable:false。目录-skill 整树哈本就跳软链(hashArtifactSource 用 isFile() 过滤)。
87
+ */
88
+ export function probeSourceState(record) {
89
+ const s = record.source;
90
+ if (s.sourceKind === 'git' && s.url)
91
+ return { reachable: true, hash: record.contentHash };
92
+ try {
93
+ if (s.sourceKind === 'git') {
94
+ const resolved = resolveInstallSource(s.locator);
95
+ try {
96
+ return { reachable: true, hash: hashArtifactSource(resolved.localRoot, resolved.isDirectorySkill) };
97
+ }
98
+ finally {
99
+ resolved.cleanup();
100
+ }
101
+ }
102
+ // 本地 file 源:守卫后再读。locator 必须是 install 实际写出的形态(绝对路径) —— 受管 JSON 随仓库
103
+ // 分发、无 opt-in 即被读到,相对 locator 不是 install 产物,拒,避免按 cwd 解析到项目外。
104
+ if (!isAbsolute(s.locator))
105
+ return { reachable: false };
106
+ const abs = resolve(s.locator);
107
+ if (!existsSync(abs))
108
+ return { reachable: false };
109
+ const st = lstatSync(abs); // lstat:不跟随软链 —— 软链直接拒(防 evil.md → /dev/zero)
110
+ if (st.isSymbolicLink())
111
+ return { reachable: false };
112
+ if (s.isDirectorySkill) {
113
+ if (!st.isDirectory())
114
+ return { reachable: false };
115
+ const hash = boundedDirSkillHash(abs); // 形态校验 + 成本边界(防任意目录递归读 / 目录 DoS)
116
+ return hash === null ? { reachable: false } : { reachable: true, hash };
117
+ }
118
+ // 单文件-skill:恢复 install(resolveFileSource)的形态约束 —— 必须是 `.md` 常规文件、非硬链、≤ size cap。
119
+ // 否则攻击者可写 locator:`/etc/passwd` / `~/.ssh/id_rsa`(非 .md 直接拒),或用 `.md` 命名的**硬链**别名
120
+ // 树外敏感文件绕过扩展名 / 软链守卫(lstat 分不出硬链)→ 诱 list 把任意本地文件读进进程参与 hash。install
121
+ // 写出的是 nlink=1 的全新副本,拒 nlink>1 不误伤合法源。非 install 形态一律 reachable:false。
122
+ if (!/\.md$/i.test(abs) || !st.isFile() || st.nlink !== 1 || st.size > MAX_FILE_SOURCE_BYTES)
123
+ return { reachable: false };
124
+ return { reachable: true, hash: hashArtifactSource(abs, false) };
125
+ }
126
+ catch {
127
+ return { reachable: false };
128
+ }
129
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * endpoint-rule — 接口驱动的自定义检查维度。
3
+ *
4
+ * 普通自定义维度走 LLM health composer(promptSection 喂 prompt);带 `endpoint`
5
+ * 的维度则注册成独立 DoctorRule:doctor 运行时把 skill 完整快照 POST 给用户的
6
+ * 接口,接口按协议返回判定结果。适合"调外部服务做深度审查"(eg. 安全风险审查)
7
+ * 这类靠 prompt 表达不了、需要真实逻辑/模型的场景。
8
+ *
9
+ * 请求协议(doctor → endpoint):
10
+ * POST <endpoint> Content-Type: application/json
11
+ * {
12
+ * "dimensionId": "deep-security-audit",
13
+ * "params": { ... }, // YAML 里用户透传的自定义参数
14
+ * "skill": {
15
+ * "name": "my-skill",
16
+ * "content": "SKILL.md 全文", // file-skill / directory-skill 主文件
17
+ * "skillRoot": "/abs/path" | null, // git / inline 来源可能为 null
18
+ * "ref": "abc1234" | null, // git 来源的 commit
19
+ * "files": { "references/x.md": "...", "scripts/y.sh": "..." } // 子文件快照
20
+ * }
21
+ * }
22
+ *
23
+ * 响应协议(endpoint → doctor):
24
+ * { "status": "pass" | "warn" | "fail", "message": "...", "hint"?: "...", "detail"?: {...} }
25
+ */
26
+ import type { DoctorRule, DoctorSeverity } from '../types/doctor.js';
27
+ export interface EndpointDimensionSpec {
28
+ id: string;
29
+ displayName: string;
30
+ severity: DoctorSeverity;
31
+ /** 检查接口地址。doctor 把 skill 快照 POST 到这里。 */
32
+ endpoint: string;
33
+ /** 用户自定义参数,原样放进请求 body.params 透传给接口。 */
34
+ params?: Record<string, unknown>;
35
+ /** 是否把子文件(references / scripts 等)内容一起传。默认 true。
36
+ * 关掉可显著缩小 payload(接口只看主文件时)。 */
37
+ includeFiles?: boolean;
38
+ /** 附加请求头(eg. 鉴权 token)。 */
39
+ headers?: Record<string, string>;
40
+ /** 单文件内容上限(字节),超过截断。默认 200KB。 */
41
+ maxFileBytes?: number;
42
+ /** 整个 files 块的总上限(字节),超过停止收集。默认 2MB。 */
43
+ maxTotalBytes?: number;
44
+ /** 是否放行私网/本机 endpoint。默认 false:hostname 为 localhost、*.local、::1、
45
+ * 127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16
46
+ * (含云 metadata 169.254.169.254)时直接 fail。动机:check() 会把 skill 完整
47
+ * 快照 POST 给 endpoint 并把响应回填进报告,等于一个 SSRF response oracle,
48
+ * 默认不能指向内网/本机。确属可信内网检查服务时显式置 true 放行。 */
49
+ allowPrivateHost?: boolean;
50
+ }
51
+ /** 可注入的 fetch(测试用),默认走全局 fetch。 */
52
+ export type FetchFn = typeof fetch;
53
+ /**
54
+ * 把一个 endpoint spec 编译成 DoctorRule。check() 内组装 skill 快照、POST、
55
+ * 校验响应协议并映射成 DoctorRuleCheckOutcome。
56
+ *
57
+ * 所有失败(网络错误 / 非 2xx / 非法 JSON / 协议字段缺失)都返回 status='fail',
58
+ * 让用户立刻看到接口侧的问题,而不是静默放行。
59
+ */
60
+ export declare function makeEndpointRule(spec: EndpointDimensionSpec, fetchFn?: FetchFn): DoctorRule;