@modusensus/dsh-mneme 0.8.12 → 0.8.14
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 +33 -5
- package/bin/dsh-mneme-serve.mjs +151 -0
- package/lib/api-standalone.js +110 -9
- package/lib/api.js +2 -0
- package/lib/client.js +12 -1
- package/lib/config.js +26 -0
- package/lib/guide.js +13 -1
- package/lib/index.js +31 -191
- package/lib/semantic.js +229 -0
- package/lib/sensitive-scan.js +209 -0
- package/lib/serve.js +215 -0
- package/lib/service.js +54 -18
- package/lib/settings.js +7 -1
- package/package.json +6 -2
- package/scripts/changelog-prep.mjs +36 -0
- package/src/api-standalone.js +110 -9
- package/src/api.js +2 -0
- package/src/config.js +26 -0
- package/src/guide.js +13 -1
- package/src/index.js +31 -191
- package/src/semantic.js +229 -0
- package/src/sensitive-scan.js +209 -0
- package/src/serve.js +215 -0
- package/src/service.js +54 -18
- package/src/settings.js +7 -1
- package/test/api.test.js +12 -2
- package/test/changelog-prep.test.js +60 -0
- package/test/inject-heat.test.js +31 -15
- package/test/inject-pools.test.js +19 -0
- package/test/scope-strict.test.js +136 -0
- package/test/sensitive-scan.test.js +207 -0
- package/test/serve-bin.test.js +121 -0
- package/test/serve.test.js +196 -0
- package/test/standalone-api.test.js +212 -5
- package/test/write-admission.test.js +115 -0
|
@@ -4,14 +4,15 @@ import { createStore } from "../src/store.js";
|
|
|
4
4
|
import { createService } from "../src/service.js";
|
|
5
5
|
import { createSettings } from "../src/settings.js";
|
|
6
6
|
import { createStandaloneApi } from "../src/api-standalone.js";
|
|
7
|
+
import { PACKAGE_VERSION } from "../src/version-check.js";
|
|
7
8
|
import { Config, applyLightModePreset } from "../src/config.js";
|
|
8
9
|
|
|
9
10
|
// Real HTTP server on an OS-assigned port (port: 0), driven with fetch.
|
|
10
|
-
async function setup() {
|
|
11
|
+
async function setup({ config = {}, embedder = null, embedTimeoutMs } = {}) {
|
|
11
12
|
const store = createStore(":memory:");
|
|
12
|
-
const service = createService({ store, mirror: null, config
|
|
13
|
+
const service = createService({ store, mirror: null, config });
|
|
13
14
|
const settings = createSettings(store.db);
|
|
14
|
-
const api = createStandaloneApi({ service, store, config
|
|
15
|
+
const api = createStandaloneApi({ service, store, config, settings, logger: null, port: 0, embedder, embedTimeoutMs });
|
|
15
16
|
await api.ready;
|
|
16
17
|
const base = `http://127.0.0.1:${api.port}`;
|
|
17
18
|
const auth = { authorization: `Bearer ${api.token}` };
|
|
@@ -40,7 +41,7 @@ test("GET /health is open without a token; every other route 401s", async () =>
|
|
|
40
41
|
assert.equal(health.status, 200);
|
|
41
42
|
assert.deepEqual(await health.json(), { ok: true });
|
|
42
43
|
|
|
43
|
-
for (const path of ["/status", "/profile", "/rules", "/memories", "/memories/x", "/search?q=x"]) {
|
|
44
|
+
for (const path of ["/status", "/profile", "/rules", "/memories", "/memories/x", "/search?q=x", "/context"]) {
|
|
44
45
|
const res = await fetch(`${base}${path}`);
|
|
45
46
|
assert.equal(res.status, 401, `${path} requires a token`);
|
|
46
47
|
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
|
@@ -249,7 +250,9 @@ test("GET /status reports version, per-type totals, entities and uptime", async
|
|
|
249
250
|
const res = await fetch(`${base}/status`, { headers: auth });
|
|
250
251
|
assert.equal(res.status, 200);
|
|
251
252
|
const body = await res.json();
|
|
252
|
-
|
|
253
|
+
// 回归锁:/status 的版本必须来自 package.json(PACKAGE_VERSION),不是散落的
|
|
254
|
+
// 字面量——曾经此处硬编码常量在发版时漏 bump,运行 0.8.13 却对外报 0.7.12。
|
|
255
|
+
assert.equal(body.version, PACKAGE_VERSION);
|
|
253
256
|
assert.equal(body.memories.total, 3);
|
|
254
257
|
assert.equal(body.memories.byType.preference, 1);
|
|
255
258
|
assert.equal(body.memories.byType.project, 2);
|
|
@@ -542,3 +545,207 @@ test("GET /search honors the occurred_at window", async () => {
|
|
|
542
545
|
close();
|
|
543
546
|
}
|
|
544
547
|
});
|
|
548
|
+
|
|
549
|
+
// --- GET /context(issue #370,#363 承诺)---------------------------------------
|
|
550
|
+
|
|
551
|
+
/** /context 用例的省事入库:走 POST /memories,与第三方调用同路径。 */
|
|
552
|
+
async function saveMemory(base, auth, body) {
|
|
553
|
+
const res = await fetch(`${base}/memories`, {
|
|
554
|
+
method: "POST",
|
|
555
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
556
|
+
body: JSON.stringify(body)
|
|
557
|
+
});
|
|
558
|
+
assert.ok(res.status === 201 || res.status === 200, `save ${body.title} → ${res.status}`);
|
|
559
|
+
return res.json();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
test("GET /search passes a declared scope through (strictScope hard wall on the search face too)", async () => {
|
|
563
|
+
// issue #370:searchMemories 本就收 scope,8790 此前没有入口——透传后第三方
|
|
564
|
+
// 声明身份,A2 软加权与 strictScope 硬过滤在检索面同样成立;不传参数行为
|
|
565
|
+
// 与既往逐字节一致(scopeFromSearchParams 返回 undefined → 不进 options)。
|
|
566
|
+
const { base, auth, close } = await setup({ config: { strictScope: true } });
|
|
567
|
+
try {
|
|
568
|
+
await saveMemory(base, auth, { type: "decision", title: "他域检索", content: "外部线索 abc", importance: 5, agent_scope: "other-agent" });
|
|
569
|
+
|
|
570
|
+
const scoped = await (await fetch(`${base}/search?q=${encodeURIComponent("外部线索")}&agent_scope=me`, { headers: auth })).json();
|
|
571
|
+
assert.ok(!scoped.items.some((m) => m.title === "他域检索"), "explicit foreign agent_scope row is hard-filtered");
|
|
572
|
+
|
|
573
|
+
const unscoped = await (await fetch(`${base}/search?q=${encodeURIComponent("外部线索")}`, { headers: auth })).json();
|
|
574
|
+
assert.ok(unscoped.items.some((m) => m.title === "他域检索"), "no scope declared → gate dormant, behavior unchanged");
|
|
575
|
+
} finally {
|
|
576
|
+
close();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("GET /context returns the injection-shaped aggregate; empty q still serves the rule tier", async () => {
|
|
581
|
+
const { base, auth, settings, close } = await setup();
|
|
582
|
+
try {
|
|
583
|
+
settings.setProfile("我是后端工程师");
|
|
584
|
+
settings.setRules(["先验证再修改"]);
|
|
585
|
+
await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 });
|
|
586
|
+
await saveMemory(base, auth, { type: "preference", title: "默认中文", content: "回复默认用中文", importance: 4 });
|
|
587
|
+
|
|
588
|
+
const res = await fetch(`${base}/context`, { headers: auth });
|
|
589
|
+
assert.equal(res.status, 200);
|
|
590
|
+
const body = await res.json();
|
|
591
|
+
// 形状锁:四键响应——bridge 等调用方按这四个键渲染,漂移(加塞/漏带)在此变红。
|
|
592
|
+
assert.deepEqual(Object.keys(body).sort(), ["items", "pinnedCount", "profile", "rules"]);
|
|
593
|
+
assert.equal(body.profile, "我是后端工程师");
|
|
594
|
+
assert.deepEqual(body.rules, ["先验证再修改"]);
|
|
595
|
+
assert.equal(body.pinnedCount, 0, "pinnedInjectBudget 默认 0(opt-in 纪律,issue #370 取舍 3)");
|
|
596
|
+
const titles = body.items.map((m) => m.title);
|
|
597
|
+
assert.ok(titles.includes("核心决策") && titles.includes("默认中文"), "empty q → pure rule tier, high-importance rows still served");
|
|
598
|
+
for (const item of body.items) {
|
|
599
|
+
assert.ok(item.id && item.type && item.title && typeof item.content === "string" && Number.isInteger(item.importance));
|
|
600
|
+
}
|
|
601
|
+
} finally {
|
|
602
|
+
close();
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("GET /context threshold is an importance gate (not /search's similarity threshold)", async () => {
|
|
607
|
+
// 同名不同义锁(issue #370 点名的文档区分):/context 的 threshold 走
|
|
608
|
+
// injectCandidates 的 importance 门(summary/preference 豁免),与相似度无关
|
|
609
|
+
// ——本用例全程无 embedder/向量,纯规则档即可验证。
|
|
610
|
+
const { base, auth, close } = await setup();
|
|
611
|
+
try {
|
|
612
|
+
await saveMemory(base, auth, { type: "decision", title: "低重要", content: "小事一桩", importance: 3 });
|
|
613
|
+
await saveMemory(base, auth, { type: "decision", title: "高重要", content: "大事记", importance: 5 });
|
|
614
|
+
|
|
615
|
+
const open = await (await fetch(`${base}/context`, { headers: auth })).json();
|
|
616
|
+
const openTitles = open.items.map((m) => m.title);
|
|
617
|
+
assert.ok(openTitles.includes("低重要") && openTitles.includes("高重要"), "default threshold=3 admits both");
|
|
618
|
+
|
|
619
|
+
const gated = await (await fetch(`${base}/context?threshold=4`, { headers: auth })).json();
|
|
620
|
+
const gatedTitles = gated.items.map((m) => m.title);
|
|
621
|
+
assert.ok(gatedTitles.includes("高重要"), "importance 5 survives threshold=4");
|
|
622
|
+
assert.ok(!gatedTitles.includes("低重要"), "importance 3 is gated out at threshold=4");
|
|
623
|
+
} finally {
|
|
624
|
+
close();
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("GET /context pins preference rows ahead when pinnedInjectBudget is on", async () => {
|
|
629
|
+
// #249 pin 池经 8790 透出。用 preference 而非 constraint:constraint 属编码
|
|
630
|
+
// 记忆,非编码查询被 codingGate 排除在候选池外,pin 池也够不着它。
|
|
631
|
+
const { base, auth, close } = await setup({ config: { pinnedInjectBudget: 2 } });
|
|
632
|
+
try {
|
|
633
|
+
await saveMemory(base, auth, { type: "preference", title: "pin-偏好", content: "永远先讲结论", importance: 5 });
|
|
634
|
+
await saveMemory(base, auth, { type: "decision", title: "普通决策", content: "先做 A 再做 B", importance: 5 });
|
|
635
|
+
|
|
636
|
+
const body = await (await fetch(`${base}/context`, { headers: auth })).json();
|
|
637
|
+
assert.equal(body.items[0].title, "pin-偏好", "pinned row leads the block");
|
|
638
|
+
assert.equal(body.pinnedCount, 1);
|
|
639
|
+
} finally {
|
|
640
|
+
close();
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("GET /context honors a declared scope under strictScope; undeclared stays unfiltered", async () => {
|
|
645
|
+
// scope 语义锁(issue #370):声明身份 → strictScope 硬过滤按 isVisibleInScope
|
|
646
|
+
// 生效;不声明 → scope=null,门不触发(与宿主「会话身份取不到时两维为 null」
|
|
647
|
+
// 同构)。任一维声明而另一维缺省时,缺维按解析不到走 fail-closed。
|
|
648
|
+
const { base, auth, close } = await setup({ config: { strictScope: true } });
|
|
649
|
+
try {
|
|
650
|
+
await saveMemory(base, auth, { type: "decision", title: "他域决策", content: "别的 agent 的事", importance: 5, agent_scope: "other-agent" });
|
|
651
|
+
await saveMemory(base, auth, { type: "decision", title: "全域决策", content: "谁都可见", importance: 5 });
|
|
652
|
+
|
|
653
|
+
const declared = await (await fetch(`${base}/context?agent_scope=me`, { headers: auth })).json();
|
|
654
|
+
const declaredTitles = declared.items.map((m) => m.title);
|
|
655
|
+
assert.ok(!declaredTitles.includes("他域决策"), "explicit foreign agent_scope row is hard-filtered");
|
|
656
|
+
assert.ok(declaredTitles.includes("全域决策"));
|
|
657
|
+
|
|
658
|
+
const undeclared = await (await fetch(`${base}/context`, { headers: auth })).json();
|
|
659
|
+
const undeclaredTitles = undeclared.items.map((m) => m.title);
|
|
660
|
+
assert.ok(undeclaredTitles.includes("他域决策"), "no declared scope → no gate");
|
|
661
|
+
} finally {
|
|
662
|
+
close();
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("GET /context embeds the query via the injected embedder (wiring lock)", async () => {
|
|
667
|
+
// embedder 接线锁(issue #370):createStandaloneApi 新增的可选 embedder 必须
|
|
668
|
+
// 真被 /context 用上——宿主与 daemon 传的都是 semantic 装配的同一实例。
|
|
669
|
+
const calls = [];
|
|
670
|
+
const fake = {
|
|
671
|
+
ready: true,
|
|
672
|
+
modelHash: "fake-hash-1",
|
|
673
|
+
dimension: 4,
|
|
674
|
+
embedSingle: async (text) => {
|
|
675
|
+
calls.push(text);
|
|
676
|
+
return [1, 0, 0, 0];
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
const { base, auth, close } = await setup({ embedder: fake });
|
|
680
|
+
try {
|
|
681
|
+
const res = await fetch(`${base}/context?q=${encodeURIComponent("语义查询")}`, { headers: auth });
|
|
682
|
+
assert.equal(res.status, 200);
|
|
683
|
+
await res.json();
|
|
684
|
+
assert.deepEqual(calls, ["语义查询"], "the route must hand the query to the injected embedder");
|
|
685
|
+
} finally {
|
|
686
|
+
close();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("GET /context survives a throwing embedder (rule-tier degradation, never 500)", async () => {
|
|
691
|
+
const { base, auth, close } = await setup({
|
|
692
|
+
embedder: { ready: true, embedSingle: async () => { throw new Error("model offline"); } }
|
|
693
|
+
});
|
|
694
|
+
try {
|
|
695
|
+
await saveMemory(base, auth, { type: "decision", title: "降级存照", content: "嵌入挂了也要有注入", importance: 5 });
|
|
696
|
+
const res = await fetch(`${base}/context?q=任意`, { headers: auth });
|
|
697
|
+
assert.equal(res.status, 200, "embed failure must degrade to the rule tier");
|
|
698
|
+
const body = await res.json();
|
|
699
|
+
assert.ok(body.items.some((m) => m.title === "降级存照"));
|
|
700
|
+
} finally {
|
|
701
|
+
close();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("GET /context times out a hung embedder and degrades (embedTimeoutMs guard)", async () => {
|
|
706
|
+
// CodeRabbit on #371:embedder 挂起(不抛错,典型为第三方 ollama/openai 端点
|
|
707
|
+
// 无响应)不能占住 HTTP 连接——超时按嵌入缺失降级规则档。测试用 50ms 短超时
|
|
708
|
+
// 避免慢测试;生产默认 3000 由工厂选项约束(同 strictPort,不进 config schema)。
|
|
709
|
+
const { base, auth, close } = await setup({
|
|
710
|
+
embedTimeoutMs: 50,
|
|
711
|
+
embedder: { ready: true, embedSingle: () => new Promise(() => {}) }
|
|
712
|
+
});
|
|
713
|
+
try {
|
|
714
|
+
await saveMemory(base, auth, { type: "decision", title: "挂起存照", content: "嵌入悬挂也要有注入", importance: 5 });
|
|
715
|
+
const started = Date.now();
|
|
716
|
+
const res = await fetch(`${base}/context?q=任意`, { headers: auth });
|
|
717
|
+
assert.equal(res.status, 200, "hung embedder must not hang the route");
|
|
718
|
+
const body = await res.json();
|
|
719
|
+
assert.ok(body.items.some((m) => m.title === "挂起存照"), "timed-out embed falls back to the rule tier");
|
|
720
|
+
assert.ok(Date.now() - started < 3000, "response must not wait on the hung embedder");
|
|
721
|
+
} finally {
|
|
722
|
+
close();
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("strictPort: busy port rejects instead of hopping; default path still hops", async () => {
|
|
727
|
+
// daemon 语义锁(#363):strictPort 下配置端口被占 = 配置错误,明确失败 ——
|
|
728
|
+
// 顺延会让把 URL 写死的第三方客户端静默打到错误端口。占口用裸 net server
|
|
729
|
+
// (OS 分配,不与并行测试抢固定号)。
|
|
730
|
+
const { default: net } = await import("node:net");
|
|
731
|
+
const blocker = net.createServer();
|
|
732
|
+
await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve));
|
|
733
|
+
const busyPort = blocker.address().port;
|
|
734
|
+
|
|
735
|
+
const store = createStore(":memory:");
|
|
736
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
737
|
+
const settings = createSettings(store.db);
|
|
738
|
+
try {
|
|
739
|
+
const strict = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: busyPort, strictPort: true });
|
|
740
|
+
await assert.rejects(strict.ready, (err) => err?.code === "EADDRINUSE");
|
|
741
|
+
|
|
742
|
+
// 默认路径(不传 strictPort)行为不变:顺延成功;具体端口不断言(避免抢号)。
|
|
743
|
+
const hopping = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: busyPort });
|
|
744
|
+
await hopping.ready;
|
|
745
|
+
assert.notEqual(hopping.port, busyPort, "default policy hops off a busy port");
|
|
746
|
+
hopping.server.close();
|
|
747
|
+
} finally {
|
|
748
|
+
blocker.close();
|
|
749
|
+
store.close();
|
|
750
|
+
}
|
|
751
|
+
});
|
|
@@ -17,6 +17,12 @@ import {
|
|
|
17
17
|
ADMISSION_TRIGGER_SOURCE
|
|
18
18
|
} from "../src/write-admission.js";
|
|
19
19
|
import { POSITIVE_SAMPLES, NEGATIVE_SAMPLES, SAMPLE_VALUES, referenceScan } from "./helpers/write-admission-samples.js";
|
|
20
|
+
// #164 A2 的真判据 + 真配置:这一组测的是**接线**(index.js 那条路径的等价物),
|
|
21
|
+
// 判据自己的验收在 test/sensitive-scan.test.js。config 用真 schema 而不是手搓对象,
|
|
22
|
+
// 钉住「键名/嵌套形状与生产一致」——手搓的 `{ sensitiveScanEnabled: true }` 在键名
|
|
23
|
+
// 写错时照样过,真 config 才会红。
|
|
24
|
+
import { Config } from "../src/config.js";
|
|
25
|
+
import { createSensitiveScan } from "../src/sensitive-scan.js";
|
|
20
26
|
|
|
21
27
|
// #254 写入准入(第一阶段:只计量,不拦截)。
|
|
22
28
|
// 本批次没有阈值、没有拦截分支,验收看两件事:默认路径零行为变化(无会话身份的
|
|
@@ -551,3 +557,112 @@ test("接线:被拒时 memory_save 返回 action=denied 与可行动的 reason
|
|
|
551
557
|
assert.ok(ok.id, "正常路径仍然返回 id");
|
|
552
558
|
});
|
|
553
559
|
|
|
560
|
+
// --- #164 A2 接线:判据自己的闸 × 闸门的 enforce -------------------------------
|
|
561
|
+
// 这一组是 index.js 那条装配路径的等价物:真 config(Config({}))+ createSensitiveScan
|
|
562
|
+
// 工厂 + 写入准入。三层开关的语义由此钉住——判据关(默认)不参与、判据开而 enforce 关
|
|
563
|
+
// 只留审计、两个都开才拦。判据本身的假阳/漏放验收在 test/sensitive-scan.test.js。
|
|
564
|
+
|
|
565
|
+
/** 用真 `Config` 解析默认值,再叠加测试要覆盖的那几个键。 */
|
|
566
|
+
function a2Store(overrides = {}) {
|
|
567
|
+
const cfg = Config(overrides);
|
|
568
|
+
const store = createStore(":memory:");
|
|
569
|
+
const writeAdmission = createWriteAdmission({
|
|
570
|
+
store,
|
|
571
|
+
config: cfg,
|
|
572
|
+
sensitiveScan: createSensitiveScan({ config: cfg })
|
|
573
|
+
});
|
|
574
|
+
const service = createService({ store, mirror: null, config: cfg, writeAdmission });
|
|
575
|
+
return { store, service, cfg };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const SECRET_ROW = {
|
|
579
|
+
type: "project",
|
|
580
|
+
title: "调试记录",
|
|
581
|
+
content: `临时代码里贴了 ${SAMPLE_VALUES.githubPat},回头删掉`
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
test("A2 默认关:写有密钥的行照常落库,且连拒绝面都不该出现", () => {
|
|
585
|
+
const { store, service } = a2Store();
|
|
586
|
+
const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
|
|
587
|
+
assert.equal(result.action, "created", "判据默认关时这条路径与 #332 逐字段一致");
|
|
588
|
+
assert.equal(rowCount(store), 1);
|
|
589
|
+
// metadata 里不该多出任何 deny/decision 键——「默认关零行为变化」是验收第 1 条,
|
|
590
|
+
// 多一个恒为 allow 的键就把它从事实变成需要解释的说法(同上面 #254 那条)。
|
|
591
|
+
const row = admissionRows(store, "s")[0];
|
|
592
|
+
assert.equal(row.metadata.deny, undefined);
|
|
593
|
+
assert.equal(row.metadata.decision, undefined);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("A2 观察档:判据开、enforce 关 → 写入照常,审计行带 kind 与 enforced=false", () => {
|
|
597
|
+
const { store, service } = a2Store({ sensitiveScanEnabled: true, writeAdmission: { enabled: true } });
|
|
598
|
+
const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
|
|
599
|
+
assert.equal(result.action, "created", "#164 口径:默认仅告警");
|
|
600
|
+
assert.equal(rowCount(store), 1);
|
|
601
|
+
const deny = admissionRows(store, "s")[0].metadata.deny;
|
|
602
|
+
assert.equal(deny.reason, "sensitive");
|
|
603
|
+
assert.equal(deny.kind, "github_token");
|
|
604
|
+
assert.equal(deny.enforced, false, "仅告警档的指纹:deny 非空、enforced=false、写入仍发生");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("A2 拦截档:判据开 + enforce 开 → 拒了、没落库、审计标 enforced", () => {
|
|
608
|
+
const { store, service } = a2Store({
|
|
609
|
+
sensitiveScanEnabled: true,
|
|
610
|
+
writeAdmission: { enabled: true, enforce: true }
|
|
611
|
+
});
|
|
612
|
+
const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
|
|
613
|
+
assert.equal(result.action, "denied");
|
|
614
|
+
assert.equal(result.reason, "sensitive");
|
|
615
|
+
assert.equal(rowCount(store), 0, "被拒的写入一条都不该落库");
|
|
616
|
+
const deny = admissionRows(store, "s")[0].metadata.deny;
|
|
617
|
+
assert.equal(deny.kind, "github_token");
|
|
618
|
+
assert.equal(deny.enforced, true);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("A2 与第 1 级空白判据互不依赖:只开 A2 不会把空白写入拦下", () => {
|
|
622
|
+
// 两个键是两批判据的闸(空白/噪声 vs 密钥/PII),分开是为了能单独观察 A2 的
|
|
623
|
+
// 命中分布——绑在一个开关上就没法只看这一类的假阳率。
|
|
624
|
+
const { store, service } = a2Store({ sensitiveScanEnabled: true });
|
|
625
|
+
const blank = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "...", content: "" });
|
|
626
|
+
assert.equal(blank.action, "created", "A2 开着不等于第 1 级判据也开");
|
|
627
|
+
assert.equal(admissionRows(store, "s")[0].metadata.deny, undefined);
|
|
628
|
+
// 反过来:只开第 1 级(writeAdmission.enabled)、A2 关,密钥行照常落库。
|
|
629
|
+
const { store: s2, service: svc2 } = a2Store({ writeAdmission: { enabled: true } });
|
|
630
|
+
assert.equal(svc2.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" }).action, "created");
|
|
631
|
+
assert.equal(s2.list({ limit: 10 }).length, 1);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("A2 跑在闸门里:只开 sensitiveScanEnabled(闸门关)时扫描器根本不参与", () => {
|
|
635
|
+
// 判据的唯一调用点是 write-admission 的 firstLevelHit,而闸门要
|
|
636
|
+
// writeAdmission.enabled 打开才走第 1 级判据。所以「本键开着」不等于「会扫」——
|
|
637
|
+
// 配置注释与面板文案都按这条写,否则用户以为单开本键就能拿到 A2 的命中分布。
|
|
638
|
+
const { store, service } = a2Store({ sensitiveScanEnabled: true });
|
|
639
|
+
const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
|
|
640
|
+
assert.equal(result.action, "created", "闸门没开时 A2 不参与判定");
|
|
641
|
+
assert.equal(rowCount(store), 1, "写入照常落库");
|
|
642
|
+
const rows = admissionRows(store, "s");
|
|
643
|
+
assert.ok(rows.length > 0, "闸门仍走计量路径(去重 / g2 不受第 1 级开关影响)");
|
|
644
|
+
assert.ok(rows.every((row) => row.metadata.deny === undefined), "一条 deny 审计都没有:第 1 级判据整体没跑");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("A2 负样本在真装配下一条都不误杀(含敏感词但没有值)", () => {
|
|
648
|
+
const { store, service } = a2Store({
|
|
649
|
+
sensitiveScanEnabled: true,
|
|
650
|
+
writeAdmission: { enabled: true, enforce: true }
|
|
651
|
+
});
|
|
652
|
+
for (const sample of NEGATIVE_SAMPLES) {
|
|
653
|
+
const result = service.saveWithDedupe({
|
|
654
|
+
_sessionKey: `sess-${sample.id}`,
|
|
655
|
+
type: "project",
|
|
656
|
+
title: sample.title,
|
|
657
|
+
content: sample.content
|
|
658
|
+
});
|
|
659
|
+
assert.equal(result.action, "created", `${sample.id} 被误杀(${sample.why})`);
|
|
660
|
+
}
|
|
661
|
+
assert.equal(rowCount(store), NEGATIVE_SAMPLES.length);
|
|
662
|
+
for (const sample of NEGATIVE_SAMPLES) {
|
|
663
|
+
for (const row of admissionRows(store, `sess-${sample.id}`)) {
|
|
664
|
+
assert.equal(row.metadata.deny, undefined, `${sample.id} 不该带任何拒绝面`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|