@nanobpm/urban 0.70.0 → 0.71.1

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.
@@ -0,0 +1,24 @@
1
+ import type { MemoryRecord } from "../schema/index.ts";
2
+ /**
3
+ * Run a git command in `cwd`, returning trimmed stdout. Every invocation is
4
+ * routed through {@link hardenedGitArgs} (the repo's single source of truth for
5
+ * `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
6
+ * hook from global templates or local config — keeping the harness deterministic
7
+ * and closing the CI code-execution vector.
8
+ */
9
+ export declare function git(cwd: string, ...args: string[]): Promise<string>;
10
+ /**
11
+ * Create a fresh temp git repo with a single seed commit on `main`, register it
12
+ * for cleanup in `roots`, and return its absolute path. This stands in for the
13
+ * local working copy S1 resolves a binding to — the substrate S3 writes and S4
14
+ * reads.
15
+ */
16
+ export declare function makeSubstrate(roots: string[]): Promise<string>;
17
+ /**
18
+ * Best-effort recursive cleanup of every registered temp root. Per-root failures
19
+ * (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
20
+ * otherwise-correct suite.
21
+ */
22
+ export declare function cleanup(roots: readonly string[]): Promise<void>;
23
+ /** A well-formed measured/authoritative record, with per-test overrides. */
24
+ export declare function rec(overrides?: Partial<MemoryRecord>): MemoryRecord;
@@ -0,0 +1,65 @@
1
+ // Slice S7 (adversarial) — shared test harness.
2
+ //
3
+ // This module is NOT a test (no `*.test.ts` suffix, so `node --test` ignores it)
4
+ // and NOT part of the published surface (the top barrel never re-exports the
5
+ // adversarial slice). It only provides the local temp-git-substrate helpers the
6
+ // adversarial suites drive S3's REAL write path through. Nothing here touches the
7
+ // network or any external service.
8
+ import { execFile } from "node:child_process";
9
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { promisify } from "node:util";
13
+ import { hardenedGitArgs } from "../binding/git-backend.js";
14
+ const execFileAsync = promisify(execFile);
15
+ /**
16
+ * Run a git command in `cwd`, returning trimmed stdout. Every invocation is
17
+ * routed through {@link hardenedGitArgs} (the repo's single source of truth for
18
+ * `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
19
+ * hook from global templates or local config — keeping the harness deterministic
20
+ * and closing the CI code-execution vector.
21
+ */
22
+ export async function git(cwd, ...args) {
23
+ const { stdout } = await execFileAsync("git", hardenedGitArgs(args), { cwd });
24
+ return stdout.trim();
25
+ }
26
+ /**
27
+ * Create a fresh temp git repo with a single seed commit on `main`, register it
28
+ * for cleanup in `roots`, and return its absolute path. This stands in for the
29
+ * local working copy S1 resolves a binding to — the substrate S3 writes and S4
30
+ * reads.
31
+ */
32
+ export async function makeSubstrate(roots) {
33
+ const dir = await mkdtemp(join(tmpdir(), "s7-substrate-"));
34
+ roots.push(dir);
35
+ await git(dir, "init");
36
+ await writeFile(join(dir, "README.md"), "# context substrate\n", "utf8");
37
+ await git(dir, "add", "-A");
38
+ await git(dir, "-c", "user.name=seed", "-c", "user.email=seed@nanobpm.local", "commit", "--no-gpg-sign", "-m", "seed");
39
+ // Normalise the default branch name regardless of the host git's default.
40
+ await git(dir, "branch", "-M", "main");
41
+ return dir;
42
+ }
43
+ /**
44
+ * Best-effort recursive cleanup of every registered temp root. Per-root failures
45
+ * (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
46
+ * otherwise-correct suite.
47
+ */
48
+ export async function cleanup(roots) {
49
+ await Promise.all(roots.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)));
50
+ }
51
+ /** A well-formed measured/authoritative record, with per-test overrides. */
52
+ export function rec(overrides = {}) {
53
+ const base = {
54
+ schemaVersion: 1,
55
+ id: "rec-1",
56
+ scope: "epic",
57
+ scopeRef: "issue-303",
58
+ mode: "empirical",
59
+ provenance: "measured",
60
+ authority: "authoritative",
61
+ statement: "the measured throughput was 42 rps",
62
+ createdAt: "2026-01-01T00:00:00Z",
63
+ };
64
+ return { ...base, ...overrides };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.70.0",
3
+ "version": "0.71.1",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -101,7 +101,7 @@
101
101
  "dependencies": {
102
102
  "@nanobpm/nano-app-schema": "^0.9.0",
103
103
  "@nanobpm/nano-sdk": "^1.2.5",
104
- "@nanobpm/workflow": "^0.12.0",
104
+ "@nanobpm/workflow": "^0.13.0",
105
105
  "bpmn-auto-layout": "^2.0.0-alpha.2",
106
106
  "create-urban-app": "^0.15.0",
107
107
  "yaml": "^2.9.0"
@@ -58,3 +58,47 @@ barrel.
58
58
  - `npm run test:conformance --workspace @nanobpm/urban` runs the conformance
59
59
  corpus.
60
60
  - `npm run check:pii --workspace @nanobpm/urban` runs the PII gate.
61
+
62
+ ## PII: no-PII by construction, and the immutability-vs-erasure boundary
63
+
64
+ The MVP substrate is **no-PII by construction**. Nothing here stores personal
65
+ data, and two independent nets keep it that way:
66
+
67
+ 1. **In-process, at write time (S6-core + S3).** `@nanobpm/urban/context/pii`
68
+ ships a pure classifier (`classifyPii`) and a mandatory, default-DENY
69
+ pre-commit guard (`preCommitPiiGuard`). S3's `ContextWriter` registers that
70
+ guard as a **non-optional** pre-commit step on **every** write path
71
+ (`PreCommitGuardRegistry`, seeded non-removably), so a record carrying PII is
72
+ rejected **before any commit** — a caller cannot opt out.
73
+
74
+ 2. **Build-time, in CI (this slice, s6-pii-ci).** `npm run check:pii` walks the
75
+ S3 record layout (`records/<scope>/<scopeRef-bucket>/<id>.json`, where
76
+ `<scopeRef-bucket>` is the sanitised `scopeRef` or the shared `_` bucket when a
77
+ record carries no `scopeRef` — see `recordRelativePath`) and re-classifies
78
+ every record with the **same** `classifyPii`, failing the build on any violation.
79
+ The `.github/workflows/urban-context-pii.yml` workflow runs it (plus
80
+ build/typecheck/test) as a second line of defence in case content reaches a
81
+ git-backed context by some path other than the writer.
82
+
83
+ ### Why the guard, not erasure
84
+
85
+ The substrate is an **append-only git history**. "Deleting" a record is just
86
+ another commit that removes the file from the *tip*; every prior value remains
87
+ in the reachable history (and in every clone, fork, and reflog). On this
88
+ substrate there is **no true erasure** — you cannot unpublish a fact that was
89
+ ever committed short of a history rewrite that every consumer must also adopt.
90
+ Because real erasure is not achievable append-only, the only sound MVP policy is
91
+ to **never admit PII in the first place** — hence the by-construction guard + CI
92
+ net above rather than an after-the-fact delete.
93
+
94
+ ### The seam left for a future PII/mutable backend
95
+
96
+ Real erasure (GDPR-style "right to be forgotten", tombstoning, crypto-shredding)
97
+ requires a **mutable** substrate that can genuinely destroy a value, not merely
98
+ append over it. The write path is already abstracted over the substrate
99
+ (`WriteSubstrate` / `GitWriteSubstrate` in `@nanobpm/urban/context/git`) and the
100
+ binding layer does not assume "public git only", so a future opt-in
101
+ PII/mutable backend can slot in behind that seam and offer erasure semantics
102
+ **without** touching the record schema, the layout, or this guard. That backend
103
+ is intentionally **not built** in the MVP — only its seam is documented here, and
104
+ the deferred implementation is tracked in nano-ide#393.
@@ -0,0 +1,89 @@
1
+ // Slice S7 (adversarial) — SCHEMA / vocabulary DRIFT guards + conformance.
2
+ //
3
+ // The shared conformance corpus (S2) is the contract BOTH producer and consumer
4
+ // are held to. Here we (a) hold the producer's own validator to the full corpus,
5
+ // and (b) attack the controlled vocabulary directly — drifted scope/mode/
6
+ // provenance/authority values, and structurally malformed records, must all be
7
+ // rejected with clear, structured errors.
8
+
9
+ import assert from "node:assert/strict";
10
+ import { test } from "node:test";
11
+ import {
12
+ assertConformance,
13
+ corpus,
14
+ fromResultValidator,
15
+ invalidFixtures,
16
+ runConformance,
17
+ validFixtures,
18
+ } from "../conformance/index.ts";
19
+ import { isMemoryRecord, validateMemoryRecord } from "../schema/index.ts";
20
+
21
+ test("the producer validator passes the FULL conformance corpus", () => {
22
+ // Result-form validator, adapted to the corpus's { ok } contract.
23
+ assert.doesNotThrow(() => assertConformance(fromResultValidator(validateMemoryRecord)));
24
+
25
+ const report = runConformance(fromResultValidator(validateMemoryRecord));
26
+ assert.equal(report.failed, 0, "no corpus case may fail");
27
+ assert.equal(report.total, corpus.length);
28
+ assert.ok(report.total > 0);
29
+ });
30
+
31
+ test("the boolean type-guard validator also conforms", () => {
32
+ const report = runConformance((input) => ({ ok: isMemoryRecord(input) }));
33
+ assert.equal(report.failed, 0);
34
+ });
35
+
36
+ test("every valid fixture is accepted and every invalid fixture is rejected", () => {
37
+ for (const c of validFixtures) {
38
+ assert.equal(validateMemoryRecord(c.input).ok, true, `should accept: ${c.name}`);
39
+ }
40
+ for (const c of invalidFixtures) {
41
+ assert.equal(validateMemoryRecord(c.input).ok, false, `should reject: ${c.name}`);
42
+ }
43
+ });
44
+
45
+ test("drifted vocabulary is rejected with the right structured code", () => {
46
+ const drifts: Array<{ field: string; value: string }> = [
47
+ { field: "scope", value: "galaxy" },
48
+ { field: "mode", value: "prescriptive" },
49
+ { field: "provenance", value: "robot" },
50
+ { field: "authority", value: "supreme" },
51
+ ];
52
+ for (const { field, value } of drifts) {
53
+ const input: Record<string, unknown> = {
54
+ schemaVersion: 1,
55
+ id: "x",
56
+ scope: "repo",
57
+ mode: "empirical",
58
+ provenance: "human",
59
+ authority: "authoritative",
60
+ statement: "s",
61
+ createdAt: "2026-01-01T00:00:00Z",
62
+ };
63
+ input[field] = value;
64
+ const result = validateMemoryRecord(input);
65
+ assert.equal(result.ok, false, `drifted ${field}=${value} must be rejected`);
66
+ if (!result.ok) {
67
+ assert.equal(
68
+ result.errors.some((e) => e.path === field && e.code === "invalid-vocabulary"),
69
+ true,
70
+ `expected invalid-vocabulary on ${field}`,
71
+ );
72
+ }
73
+ }
74
+ });
75
+
76
+ test("hostile inputs never crash the validator (throwing getters, exotic shapes)", () => {
77
+ const hostile = {
78
+ get id() {
79
+ throw new Error("boom");
80
+ },
81
+ };
82
+ // Must return a structured rejection, not propagate the throw.
83
+ assert.doesNotThrow(() => validateMemoryRecord(hostile));
84
+ assert.equal(validateMemoryRecord(hostile).ok, false);
85
+
86
+ for (const value of [42, null, undefined, "string", [], new Date()]) {
87
+ assert.equal(validateMemoryRecord(value).ok, false);
88
+ }
89
+ });
@@ -0,0 +1,97 @@
1
+ // Slice S7 (adversarial) — GOVERNANCE can't be bypassed.
2
+ //
3
+ // A proposed prior is a bot "PR": it lives on its own branch and is NOT on the
4
+ // authoritative line until MERGED (ratified). These tests prove an unmerged
5
+ // proposal never acts as a ratified one, and that the ratify path refuses to be
6
+ // steered onto an arbitrary branch or base.
7
+
8
+ import assert from "node:assert/strict";
9
+ import { after, test } from "node:test";
10
+ import { ContextWriter, GovernanceError } from "../git/index.ts";
11
+ import { ContextRetriever } from "../retrieval/index.ts";
12
+ import { cleanup, git, makeSubstrate, rec } from "./harness.ts";
13
+
14
+ const TEMP_ROOTS: string[] = [];
15
+ after(() => cleanup(TEMP_ROOTS));
16
+
17
+ function retro(id: string) {
18
+ return rec({
19
+ id,
20
+ provenance: "agent-retro",
21
+ mode: "normative",
22
+ authority: "hypothesis",
23
+ statement: `proposed prior ${id}`,
24
+ });
25
+ }
26
+
27
+ test("an UNMERGED proposal is invisible on the authoritative line", async () => {
28
+ const dir = await makeSubstrate(TEMP_ROOTS);
29
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
30
+
31
+ const proposal = await writer.proposePrior(retro("gov-1"));
32
+ assert.equal(proposal.ratified, false);
33
+
34
+ // Before ratifying, a reader on main sees nothing — the hypothesis is parked
35
+ // on its own branch, not on the base line.
36
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
37
+ assert.equal((await retriever.all()).some((s) => s.record.id === "gov-1"), false);
38
+
39
+ // Only the merge ratifies it onto main.
40
+ await writer.ratify(proposal);
41
+ retriever.invalidate();
42
+ assert.equal((await retriever.all()).some((s) => s.record.id === "gov-1"), true);
43
+ });
44
+
45
+ test("ratify refuses a branch outside the proposal namespace", async () => {
46
+ const dir = await makeSubstrate(TEMP_ROOTS);
47
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
48
+
49
+ // A real, existing branch that is NOT a proposal branch.
50
+ await git(dir, "branch", "sneaky-branch", "main");
51
+
52
+ await assert.rejects(
53
+ () =>
54
+ writer.ratify({
55
+ path: "whatever",
56
+ commit: "deadbeef",
57
+ branch: "sneaky-branch",
58
+ baseBranch: "main",
59
+ proposalId: "forged",
60
+ ratified: false,
61
+ }),
62
+ GovernanceError,
63
+ );
64
+ });
65
+
66
+ test("ratify refuses a proposal whose base was steered off the writer's own base", async () => {
67
+ const dir = await makeSubstrate(TEMP_ROOTS);
68
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
69
+
70
+ const proposal = await writer.proposePrior(retro("gov-steer"));
71
+ // ProposalResult is a plain object; a hostile caller mutates the target base.
72
+ await assert.rejects(
73
+ () => writer.ratify({ ...proposal, baseBranch: "some-other-branch" }),
74
+ GovernanceError,
75
+ );
76
+
77
+ // The proposal is still unratified: main must NOT contain it.
78
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
79
+ assert.equal((await retriever.all()).some((s) => s.record.id === "gov-steer"), false);
80
+ });
81
+
82
+ test("ratify refuses a non-existent proposal branch", async () => {
83
+ const dir = await makeSubstrate(TEMP_ROOTS);
84
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
85
+ await assert.rejects(
86
+ () =>
87
+ writer.ratify({
88
+ path: "p",
89
+ commit: "c",
90
+ branch: "context/proposal/does-not-exist",
91
+ baseBranch: "main",
92
+ proposalId: "ghost",
93
+ ratified: false,
94
+ }),
95
+ GovernanceError,
96
+ );
97
+ });
@@ -0,0 +1,84 @@
1
+ // Slice S7 (adversarial) — shared test harness.
2
+ //
3
+ // This module is NOT a test (no `*.test.ts` suffix, so `node --test` ignores it)
4
+ // and NOT part of the published surface (the top barrel never re-exports the
5
+ // adversarial slice). It only provides the local temp-git-substrate helpers the
6
+ // adversarial suites drive S3's REAL write path through. Nothing here touches the
7
+ // network or any external service.
8
+
9
+ import { execFile } from "node:child_process";
10
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { promisify } from "node:util";
14
+ import { hardenedGitArgs } from "../binding/git-backend.ts";
15
+ import type { MemoryRecord } from "../schema/index.ts";
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /**
20
+ * Run a git command in `cwd`, returning trimmed stdout. Every invocation is
21
+ * routed through {@link hardenedGitArgs} (the repo's single source of truth for
22
+ * `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
23
+ * hook from global templates or local config — keeping the harness deterministic
24
+ * and closing the CI code-execution vector.
25
+ */
26
+ export async function git(cwd: string, ...args: string[]): Promise<string> {
27
+ const { stdout } = await execFileAsync("git", hardenedGitArgs(args), { cwd });
28
+ return stdout.trim();
29
+ }
30
+
31
+ /**
32
+ * Create a fresh temp git repo with a single seed commit on `main`, register it
33
+ * for cleanup in `roots`, and return its absolute path. This stands in for the
34
+ * local working copy S1 resolves a binding to — the substrate S3 writes and S4
35
+ * reads.
36
+ */
37
+ export async function makeSubstrate(roots: string[]): Promise<string> {
38
+ const dir = await mkdtemp(join(tmpdir(), "s7-substrate-"));
39
+ roots.push(dir);
40
+ await git(dir, "init");
41
+ await writeFile(join(dir, "README.md"), "# context substrate\n", "utf8");
42
+ await git(dir, "add", "-A");
43
+ await git(
44
+ dir,
45
+ "-c",
46
+ "user.name=seed",
47
+ "-c",
48
+ "user.email=seed@nanobpm.local",
49
+ "commit",
50
+ "--no-gpg-sign",
51
+ "-m",
52
+ "seed",
53
+ );
54
+ // Normalise the default branch name regardless of the host git's default.
55
+ await git(dir, "branch", "-M", "main");
56
+ return dir;
57
+ }
58
+
59
+ /**
60
+ * Best-effort recursive cleanup of every registered temp root. Per-root failures
61
+ * (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
62
+ * otherwise-correct suite.
63
+ */
64
+ export async function cleanup(roots: readonly string[]): Promise<void> {
65
+ await Promise.all(
66
+ roots.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)),
67
+ );
68
+ }
69
+
70
+ /** A well-formed measured/authoritative record, with per-test overrides. */
71
+ export function rec(overrides: Partial<MemoryRecord> = {}): MemoryRecord {
72
+ const base: MemoryRecord = {
73
+ schemaVersion: 1,
74
+ id: "rec-1",
75
+ scope: "epic",
76
+ scopeRef: "issue-303",
77
+ mode: "empirical",
78
+ provenance: "measured",
79
+ authority: "authoritative",
80
+ statement: "the measured throughput was 42 rps",
81
+ createdAt: "2026-01-01T00:00:00Z",
82
+ };
83
+ return { ...base, ...overrides };
84
+ }
@@ -0,0 +1,119 @@
1
+ // Slice S7 (adversarial) — the no-PII guard holds BY CONSTRUCTION and under
2
+ // evasion.
3
+ //
4
+ // The DEFAULT S3 write path (no special caller wiring) must reject a
5
+ // PII-carrying record because S3 pre-registers the mandatory S6 guard. These
6
+ // tests prove that, then attempt obfuscated / nested / edge-case PII and confirm
7
+ // the guard still blocks it — and that a caller cannot bypass it.
8
+
9
+ import assert from "node:assert/strict";
10
+ import { after, test } from "node:test";
11
+ import { ContextWriter } from "../git/index.ts";
12
+ import { PiiGuardError, type PiiGuard, classifyPii, preCommitPiiGuard } from "../pii/index.ts";
13
+ import { ContextRetriever } from "../retrieval/index.ts";
14
+ import { cleanup, makeSubstrate, rec } from "./harness.ts";
15
+
16
+ const TEMP_ROOTS: string[] = [];
17
+ after(() => cleanup(TEMP_ROOTS));
18
+
19
+ test("the DEFAULT write path (no wiring) rejects a PII-carrying record", async () => {
20
+ const dir = await makeSubstrate(TEMP_ROOTS);
21
+ // Plain default construction — the caller does NOT opt into any guard.
22
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
23
+
24
+ // The mandatory PII guard is present — and FIRST — by construction.
25
+ assert.equal(
26
+ writer.guards[0],
27
+ preCommitPiiGuard,
28
+ "the mandatory PII guard must be registered first",
29
+ );
30
+
31
+ await assert.rejects(
32
+ () => writer.appendRecord(rec({ id: "pii-plain", statement: "reach me at alice@example.com" })),
33
+ PiiGuardError,
34
+ );
35
+
36
+ // Governance path is guarded too.
37
+ await assert.rejects(
38
+ () =>
39
+ writer.proposePrior(
40
+ rec({
41
+ id: "pii-propose",
42
+ provenance: "agent-retro",
43
+ mode: "normative",
44
+ authority: "hypothesis",
45
+ statement: "ssn 123-45-6789 belongs to the reporter",
46
+ }),
47
+ ),
48
+ PiiGuardError,
49
+ );
50
+
51
+ // Nothing leaked into the store.
52
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
53
+ assert.equal((await retriever.all()).length, 0);
54
+ });
55
+
56
+ test("PII hidden in a NESTED field (evidence array) is still blocked", async () => {
57
+ const dir = await makeSubstrate(TEMP_ROOTS);
58
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
59
+ await assert.rejects(
60
+ () =>
61
+ writer.appendRecord(
62
+ rec({
63
+ id: "pii-nested",
64
+ statement: "clean summary",
65
+ evidence: ["run-1", "leaked contact alice@example.com in logs"],
66
+ }),
67
+ ),
68
+ PiiGuardError,
69
+ );
70
+ });
71
+
72
+ test("OBFUSCATED email evades a naive scan but not the guard", () => {
73
+ // The classifier's de-obfuscation pass catches spelled-out separators.
74
+ const obfuscated = "contact bob [at] example [dot] com for details";
75
+ const result = classifyPii(obfuscated);
76
+ assert.equal(result.clean, false, "obfuscated email must be detected");
77
+ assert.equal(result.findings.some((f) => f.kind === "email"), true);
78
+
79
+ // The default guard throws on it, mirroring the write path.
80
+ assert.throws(() => preCommitPiiGuard.assert(obfuscated), PiiGuardError);
81
+ });
82
+
83
+ test("clean content passes the guard and lands", async () => {
84
+ const dir = await makeSubstrate(TEMP_ROOTS);
85
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
86
+ await writer.appendRecord(rec({ id: "clean-1", statement: "throughput improved to 42 rps" }));
87
+
88
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
89
+ assert.equal((await retriever.all()).some((s) => s.record.id === "clean-1"), true);
90
+ });
91
+
92
+ test("an extra caller guard can only make enforcement STRICTER, never bypass PII", async () => {
93
+ const dir = await makeSubstrate(TEMP_ROOTS);
94
+ // A DISTINCT, always-throwing caller guard. If a caller-supplied guard could
95
+ // displace the mandatory PII guard — or run before it — a PII write would
96
+ // surface THIS sentinel error instead of PiiGuardError.
97
+ const alwaysThrows: PiiGuard = {
98
+ name: "always-throws",
99
+ inspect: () => ({ clean: true, findings: [] }),
100
+ assert: () => {
101
+ throw new Error("extra caller guard ran");
102
+ },
103
+ };
104
+ const writer = new ContextWriter(
105
+ { localPath: dir, ref: "main" },
106
+ { guards: [alwaysThrows] },
107
+ );
108
+ // The mandatory guard is still FIRST; the caller guard is only appended.
109
+ assert.equal(writer.guards[0], preCommitPiiGuard, "mandatory PII guard must run first");
110
+ assert.equal(writer.guards.includes(alwaysThrows), true, "caller guard is added, not dropped");
111
+
112
+ // A PII write is rejected by the mandatory guard (which runs first) — with
113
+ // PiiGuardError, NOT the sentinel — proving the caller guard neither displaced
114
+ // nor pre-empted mandatory enforcement.
115
+ await assert.rejects(
116
+ () => writer.appendRecord(rec({ id: "pii-extra", statement: "email me: eve@example.org" })),
117
+ PiiGuardError,
118
+ );
119
+ });
@@ -0,0 +1,126 @@
1
+ // Slice S7 (adversarial) — PROVENANCE / AUTHORITY forgery resistance.
2
+ //
3
+ // The headline invariant of the whole layer is "hypothesis-never-fix": an
4
+ // unratified / agent-retrospective prior can never be REPRESENTED as a measured
5
+ // or authoritative fact. These tests attack that invariant directly — at the
6
+ // schema, at the write path, and after ratification — at EVERY scope.
7
+
8
+ import assert from "node:assert/strict";
9
+ import { after, test } from "node:test";
10
+ import { ContextWriter } from "../git/index.ts";
11
+ import { ContextRetriever } from "../retrieval/index.ts";
12
+ import {
13
+ MemoryRecordValidationError,
14
+ type MemoryScope,
15
+ SCOPE_LADDER,
16
+ assertMemoryRecord,
17
+ isAuthoritative,
18
+ validateMemoryRecord,
19
+ } from "../schema/index.ts";
20
+ import { cleanup, makeSubstrate, rec } from "./harness.ts";
21
+
22
+ const TEMP_ROOTS: string[] = [];
23
+ after(() => cleanup(TEMP_ROOTS));
24
+
25
+ /** The forgery target: an agent-retro prior masquerading as an authoritative fact. */
26
+ function forgedAtScope(scope: MemoryScope): Record<string, unknown> {
27
+ return {
28
+ schemaVersion: 1,
29
+ id: `forged-${scope}`,
30
+ scope,
31
+ mode: "empirical",
32
+ provenance: "agent-retro",
33
+ authority: "authoritative",
34
+ statement: "a hypothesis laundered into a fact",
35
+ createdAt: "2026-01-01T00:00:00Z",
36
+ };
37
+ }
38
+
39
+ test("schema: agent-retro can NEVER be authoritative — at every scope", () => {
40
+ for (const scope of SCOPE_LADDER) {
41
+ const result = validateMemoryRecord(forgedAtScope(scope));
42
+ assert.equal(result.ok, false, `scope ${scope}: forged record must be rejected`);
43
+ if (!result.ok) {
44
+ assert.equal(
45
+ result.errors.some((e) => e.code === "invariant-violation"),
46
+ true,
47
+ `scope ${scope}: expected an invariant-violation`,
48
+ );
49
+ }
50
+ assert.throws(() => assertMemoryRecord(forgedAtScope(scope)), MemoryRecordValidationError);
51
+ }
52
+ });
53
+
54
+ test("schema: no field permutation reaches authoritative via agent-retro", () => {
55
+ // The ONLY provenances that may be authoritative are human/measured/instance.
56
+ for (const mode of ["normative", "empirical"] as const) {
57
+ const result = validateMemoryRecord({
58
+ schemaVersion: 1,
59
+ id: "x",
60
+ scope: "repo",
61
+ mode,
62
+ provenance: "agent-retro",
63
+ authority: "authoritative",
64
+ statement: "s",
65
+ createdAt: "2026-01-01T00:00:00Z",
66
+ });
67
+ assert.equal(result.ok, false, `agent-retro/${mode}/authoritative must be rejected`);
68
+ }
69
+ });
70
+
71
+ test("write path: a forged agent-retro-authoritative record cannot be persisted", async () => {
72
+ const dir = await makeSubstrate(TEMP_ROOTS);
73
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
74
+
75
+ // Neither write path will commit it: append validates+rejects the invariant
76
+ // (and also refuses agent-retro directly), propose validates+rejects it too.
77
+ await assert.rejects(() => writer.appendRecord(forgedAtScope("epic")));
78
+ await assert.rejects(() => writer.proposePrior(forgedAtScope("epic")));
79
+
80
+ // And nothing landed in the store.
81
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
82
+ const all = await retriever.all();
83
+ assert.equal(all.some((s) => s.record.id === "forged-epic"), false);
84
+ assert.equal(all.length, 0);
85
+ });
86
+
87
+ test("write path: agent-retro is refused on the DIRECT append path (must go via governance)", async () => {
88
+ const dir = await makeSubstrate(TEMP_ROOTS);
89
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
90
+ // A perfectly valid agent-retro hypothesis is still not directly appendable.
91
+ await assert.rejects(() =>
92
+ writer.appendRecord(
93
+ rec({
94
+ id: "retro-direct",
95
+ provenance: "agent-retro",
96
+ mode: "normative",
97
+ authority: "hypothesis",
98
+ statement: "prefer X over Y",
99
+ }),
100
+ ),
101
+ );
102
+ });
103
+
104
+ test("ratification does NOT upgrade authority — the merged record stays a hypothesis", async () => {
105
+ const dir = await makeSubstrate(TEMP_ROOTS);
106
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
107
+
108
+ const proposal = await writer.proposePrior(
109
+ rec({
110
+ id: "retro-1",
111
+ provenance: "agent-retro",
112
+ mode: "normative",
113
+ authority: "hypothesis",
114
+ statement: "a proposed prior",
115
+ }),
116
+ );
117
+ await writer.ratify(proposal);
118
+
119
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
120
+ const found = (await retriever.all()).find((s) => s.record.id === "retro-1");
121
+ assert.ok(found, "the ratified proposal is now on the authoritative line");
122
+ // Ratified ≠ authoritative: the record is an ACCEPTED hypothesis, never a fact.
123
+ assert.equal(found.record.authority, "hypothesis");
124
+ assert.equal(found.record.provenance, "agent-retro");
125
+ assert.equal(isAuthoritative(found.record), false);
126
+ });
@@ -0,0 +1,104 @@
1
+ // Slice S7 (adversarial) — the pluggable SEAMS don't leak a backend assumption.
2
+ //
3
+ // Retrieval must be correct from a COLD/empty derived cache (git is
4
+ // authoritative, the cache is a disposable projection), and the binding / read
5
+ // seams must NOT hard-wire "exclusively public git" — local paths, file:// URLs
6
+ // and ssh remotes are first-class, and a custom RecordSource can back retrieval
7
+ // without any git at all.
8
+
9
+ import assert from "node:assert/strict";
10
+ import { after, test } from "node:test";
11
+ import { resolveContextIdentity, sameContext } from "../binding/index.ts";
12
+ import { ContextWriter } from "../git/index.ts";
13
+ import {
14
+ ContextRetriever,
15
+ type NamespaceSelector,
16
+ type RecordSource,
17
+ type StoredRecord,
18
+ } from "../retrieval/index.ts";
19
+ import type { MemoryRecord } from "../schema/index.ts";
20
+ import { cleanup, makeSubstrate, rec } from "./harness.ts";
21
+
22
+ const TEMP_ROOTS: string[] = [];
23
+ after(() => cleanup(TEMP_ROOTS));
24
+
25
+ test("retrieval is correct on a COLD cache — the first query falls back to git", async () => {
26
+ const dir = await makeSubstrate(TEMP_ROOTS);
27
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
28
+ await writer.appendRecord(rec({ id: "cold-1", scope: "epic", scopeRef: "issue-303" }));
29
+ await writer.appendRecord(rec({ id: "cold-2", scope: "repo", scopeRef: "nano-ide", statement: "s2" }));
30
+
31
+ // Brand-new retriever: the derived cache is cold. This FIRST query has no
32
+ // warmed snapshot to read, so it falls back to git — and, as a side effect,
33
+ // populates the snapshot. We assert the cold-path result is complete.
34
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
35
+ const cold = await retriever.query({});
36
+ assert.equal(cold.length, 2, "a cold cache must still return every record via git");
37
+
38
+ // A path-scoped structured query on a COLD cache reads just that partition
39
+ // without forcing a full snapshot build. Use a FRESH retriever so the cache is
40
+ // genuinely cold here — the `query({})` above already warmed `retriever`.
41
+ const nsRetriever = new ContextRetriever({ localPath: dir, ref: "main" });
42
+ assert.equal(nsRetriever.cache.isWarm, false, "a fresh retriever's cache must be cold");
43
+ const scoped = await nsRetriever.byNamespace("repo", "nano-ide");
44
+ assert.equal(scoped.length, 1);
45
+ assert.equal(scoped[0].record.id, "cold-2");
46
+ assert.equal(nsRetriever.cache.isWarm, false, "a single-namespace query must not warm the full cache");
47
+ });
48
+
49
+ test("git is authoritative — the derived cache is disposable and rebuildable", async () => {
50
+ const dir = await makeSubstrate(TEMP_ROOTS);
51
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
52
+ await writer.appendRecord(rec({ id: "auth-1" }));
53
+
54
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
55
+ assert.equal((await retriever.all()).length, 1); // warms the cache
56
+
57
+ // A write happens in git AFTER the cache was warmed.
58
+ await writer.appendRecord(rec({ id: "auth-2", scope: "repo", scopeRef: "nano-ide", statement: "later" }));
59
+
60
+ // The stale snapshot doesn't see it; invalidating rebuilds purely from git.
61
+ retriever.invalidate();
62
+ const rebuilt = await retriever.all();
63
+ assert.equal(rebuilt.length, 2, "cache must rebuild the full truth from git");
64
+ assert.equal(rebuilt.some((s) => s.record.id === "auth-2"), true);
65
+
66
+ // The forward-compat embedding seam is opt-in and absent in the MVP.
67
+ assert.equal(retriever.embeddingIndex, undefined);
68
+ });
69
+
70
+ test("the READ seam is pluggable — retrieval works over a non-git RecordSource", async () => {
71
+ const record: MemoryRecord = rec({ id: "mem-1", subject: "latency", statement: "backend-agnostic" });
72
+ const stored: StoredRecord = { path: "records/epic/issue-303/mem-1.md", record };
73
+
74
+ // An in-memory source that never touches git — proving the seam isn't
75
+ // hard-wired to "exclusively public git".
76
+ const memorySource: RecordSource = {
77
+ async list(_selector?: NamespaceSelector) {
78
+ return [stored];
79
+ },
80
+ async read(relPath: string) {
81
+ return relPath === stored.path ? stored : undefined;
82
+ },
83
+ };
84
+
85
+ const retriever = new ContextRetriever("/unused/path", { source: memorySource });
86
+ const all = await retriever.all();
87
+ assert.equal(all.length, 1);
88
+ assert.equal(all[0].record.id, "mem-1");
89
+ const bySubject = await retriever.query({ subject: "latency" });
90
+ assert.equal(bySubject.length, 1);
91
+ });
92
+
93
+ test("binding identity is NOT hard-wired to public GitHub — local/file/ssh are first-class", () => {
94
+ // A local path and its file:// spelling name the SAME (self-hosted) context.
95
+ const localPath = "/srv/substrates/ctx";
96
+ const local = resolveContextIdentity({ repo: localPath, ref: "main" });
97
+ assert.ok(local.key.length > 0, "a local-path substrate must resolve to an identity");
98
+ assert.equal(sameContext({ repo: localPath, ref: "main" }, { repo: `file://${localPath}`, ref: "main" }), true);
99
+
100
+ // An ssh remote resolves too, and differs from an unrelated host.
101
+ const ssh = resolveContextIdentity({ repo: "git@example.com:team/ctx.git", ref: "main" });
102
+ assert.ok(ssh.key.length > 0);
103
+ assert.notEqual(ssh.key, local.key);
104
+ });
@@ -0,0 +1,84 @@
1
+ // Slice S7 (adversarial) — cross-instance SHARING semantics.
2
+ //
3
+ // Proves the "private-per-app / shared-on-same-name" contract can't be broken:
4
+ // two instances naming the SAME context (repo+ref) see each other's ratified
5
+ // writes, and two instances naming DISTINCT contexts stay isolated. Exercised at
6
+ // both the S1 identity layer and end-to-end through S3's write path + S4 reads.
7
+
8
+ import assert from "node:assert/strict";
9
+ import { after, test } from "node:test";
10
+ import {
11
+ type ContextBinding,
12
+ contextIdentityKey,
13
+ resolveContextIdentity,
14
+ sameContext,
15
+ } from "../binding/index.ts";
16
+ import { ContextWriter } from "../git/index.ts";
17
+ import { ContextRetriever } from "../retrieval/index.ts";
18
+ import { cleanup, makeSubstrate, rec } from "./harness.ts";
19
+
20
+ const TEMP_ROOTS: string[] = [];
21
+ after(() => cleanup(TEMP_ROOTS));
22
+
23
+ const binding = (repo: string, ref: string): ContextBinding => ({ repo, ref });
24
+
25
+ test("identity: same (repo, ref) name shares; a distinct name stays private", () => {
26
+ const a = binding("nanobpm/nano-ide", "main");
27
+ const same = binding("nanobpm/nano-ide", "main");
28
+ const otherRef = binding("nanobpm/nano-ide", "release");
29
+ const otherRepo = binding("nanobpm/nano-workforce", "main");
30
+
31
+ assert.equal(sameContext(a, same), true);
32
+ assert.equal(contextIdentityKey(a), contextIdentityKey(same));
33
+
34
+ // A different ref OR a different repo is a genuinely different context.
35
+ assert.equal(sameContext(a, otherRef), false);
36
+ assert.equal(sameContext(a, otherRepo), false);
37
+ assert.notEqual(contextIdentityKey(a), contextIdentityKey(otherRef));
38
+ assert.notEqual(contextIdentityKey(a), contextIdentityKey(otherRepo));
39
+
40
+ // On-disk slug tracks the key: shared names → shared location; distinct → not.
41
+ assert.equal(resolveContextIdentity(a).slug, resolveContextIdentity(same).slug);
42
+ assert.notEqual(resolveContextIdentity(a).slug, resolveContextIdentity(otherRef).slug);
43
+ });
44
+
45
+ test("identity: equivalent repo spellings collapse to one shared substrate", () => {
46
+ // Shorthand, HTTPS clone URL, and a credentialed URL all name one context.
47
+ // The credentialed form uses the repo's redacted userinfo placeholder (`***@`)
48
+ // — it still exercises the "userinfo must not affect identity" invariant
49
+ // without embedding a credential-in-URL literal that trips secret scanning.
50
+ const shorthand = binding("nanobpm/nano-ide", "main");
51
+ const https = binding("https://github.com/nanobpm/nano-ide.git", "main");
52
+ const credential = binding("https://***@github.com/nanobpm/nano-ide.git", "main");
53
+
54
+ assert.equal(sameContext(shorthand, https), true);
55
+ assert.equal(sameContext(shorthand, credential), true, "credentials must not split identity");
56
+ });
57
+
58
+ test("end-to-end: a second instance on the same context reads the first's write", async () => {
59
+ const shared = await makeSubstrate(TEMP_ROOTS);
60
+
61
+ // Instance A writes a ratified fact into the shared substrate.
62
+ const writerA = new ContextWriter({ localPath: shared, ref: "main" });
63
+ await writerA.appendRecord(
64
+ rec({ id: "shared-fact", scope: "epic", scopeRef: "issue-303", statement: "A observed 42 rps" }),
65
+ );
66
+
67
+ // Instance B — a distinct object naming the SAME context — sees it.
68
+ const retrieverB = new ContextRetriever({ localPath: shared, ref: "main" });
69
+ const seenByB = await retrieverB.all();
70
+ assert.equal(seenByB.some((s) => s.record.id === "shared-fact"), true);
71
+ });
72
+
73
+ test("end-to-end: a distinct-name instance cannot see another context's writes", async () => {
74
+ const shared = await makeSubstrate(TEMP_ROOTS);
75
+ const other = await makeSubstrate(TEMP_ROOTS);
76
+
77
+ const writerA = new ContextWriter({ localPath: shared, ref: "main" });
78
+ await writerA.appendRecord(rec({ id: "private-fact", statement: "only visible in shared" }));
79
+
80
+ const retrieverOther = new ContextRetriever({ localPath: other, ref: "main" });
81
+ const seen = await retrieverOther.all();
82
+ assert.equal(seen.some((s) => s.record.id === "private-fact"), false);
83
+ assert.equal(seen.length, 0, "an unrelated context must stay empty/private");
84
+ });
@@ -0,0 +1,196 @@
1
+ // Slice s6-pii-ci — tests for the build-time PII gate (`scripts/check-pii.ts`).
2
+ //
3
+ // These seed records into the SAME S3 path/namespace layout the writer uses
4
+ // (`recordRelativePath`), then run the real check-pii.ts script against a local
5
+ // temp substrate and assert its exit code: non-zero on a seeded PII violation,
6
+ // zero on clean content. Nothing touches the network.
7
+
8
+ import assert from "node:assert/strict";
9
+ import { execFile } from "node:child_process";
10
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { after, test } from "node:test";
14
+ import { fileURLToPath } from "node:url";
15
+ import { promisify } from "node:util";
16
+ import { recordRelativePath } from "../git/index.ts";
17
+ import type { MemoryRecord } from "../schema/index.ts";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ // packages/urban/src/context/pii → packages/urban/scripts/check-pii.ts
22
+ const CHECK_PII = join(HERE, "..", "..", "..", "scripts", "check-pii.ts");
23
+ const TEMP_ROOTS: string[] = [];
24
+
25
+ after(async () => {
26
+ await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true })));
27
+ });
28
+
29
+ function record(overrides: Partial<MemoryRecord>): MemoryRecord {
30
+ const base: MemoryRecord = {
31
+ schemaVersion: 1,
32
+ id: "rec-1",
33
+ scope: "epic",
34
+ scopeRef: "issue-303",
35
+ mode: "empirical",
36
+ provenance: "measured",
37
+ authority: "authoritative",
38
+ statement: "the measured throughput was 42 rps",
39
+ createdAt: "2026-01-01T00:00:00Z",
40
+ };
41
+ return { ...base, ...overrides };
42
+ }
43
+
44
+ /** Write a record into a substrate root at its canonical S3 layout path. */
45
+ async function seed(root: string, rec: MemoryRecord): Promise<void> {
46
+ const rel = recordRelativePath(rec);
47
+ const abs = join(root, rel);
48
+ await mkdir(dirname(abs), { recursive: true });
49
+ await writeFile(abs, `${JSON.stringify(rec, null, 2)}\n`, "utf8");
50
+ }
51
+
52
+ /** Write raw file content into a substrate root at a record's canonical path. */
53
+ async function seedRaw(root: string, rec: MemoryRecord, content: string): Promise<void> {
54
+ const rel = recordRelativePath(rec);
55
+ const abs = join(root, rel);
56
+ await mkdir(dirname(abs), { recursive: true });
57
+ await writeFile(abs, content, "utf8");
58
+ }
59
+
60
+ async function makeRoot(): Promise<string> {
61
+ const dir = await mkdtemp(join(tmpdir(), "s6ci-substrate-"));
62
+ TEMP_ROOTS.push(dir);
63
+ return dir;
64
+ }
65
+
66
+ /** Run check-pii.ts against a root; resolve with its exit code + stderr. */
67
+ async function runCheck(root: string): Promise<{ code: number; stderr: string; stdout: string }> {
68
+ try {
69
+ const { stdout, stderr } = await execFileAsync(
70
+ process.execPath,
71
+ ["--experimental-strip-types", CHECK_PII, root],
72
+ { cwd: root },
73
+ );
74
+ return { code: 0, stdout, stderr };
75
+ } catch (error) {
76
+ return {
77
+ code: numberProp(error, "code") ?? 1,
78
+ stdout: stringProp(error, "stdout"),
79
+ stderr: stringProp(error, "stderr"),
80
+ };
81
+ }
82
+ }
83
+
84
+ function numberProp(error: unknown, key: string): number | undefined {
85
+ const value =
86
+ typeof error === "object" && error !== null ? Reflect.get(error, key) : undefined;
87
+ return typeof value === "number" ? value : undefined;
88
+ }
89
+
90
+ function stringProp(error: unknown, key: string): string {
91
+ const value =
92
+ typeof error === "object" && error !== null ? Reflect.get(error, key) : undefined;
93
+ return typeof value === "string" ? value : "";
94
+ }
95
+
96
+ test("check:pii passes (exit 0) on clean records in the S3 layout", async () => {
97
+ const root = await makeRoot();
98
+ await seed(root, record({ id: "clean-a" }));
99
+ await seed(
100
+ root,
101
+ record({ id: "clean-b", scope: "repo", scopeRef: undefined, statement: "latency held at 42ms" }),
102
+ );
103
+
104
+ const { code, stdout } = await runCheck(root);
105
+ assert.equal(code, 0, `expected clean content to pass; stdout=${stdout}`);
106
+ assert.match(stdout, /no PII detected/);
107
+ });
108
+
109
+ test("check:pii fails (non-zero) on a seeded PII violation in the S3 layout", async () => {
110
+ const root = await makeRoot();
111
+ await seed(root, record({ id: "clean-a" }));
112
+ // A record whose statement carries an email address — a PII violation.
113
+ await seed(
114
+ root,
115
+ record({ id: "leaky", statement: "reported by alice.example@contoso.com during the run" }),
116
+ );
117
+
118
+ const { code, stderr } = await runCheck(root);
119
+ assert.equal(code, 1, "expected a seeded PII violation to fail the build");
120
+ assert.match(stderr, /PII detected/);
121
+ assert.match(stderr, /email/);
122
+ // The gate must NOT re-leak the raw PII it caught (length-only redaction).
123
+ assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
124
+ });
125
+
126
+ test("check:pii catches PII in a JSON array record (arrays are not plain objects)", async () => {
127
+ const root = await makeRoot();
128
+ // A record file whose top-level JSON value is an array carrying an email
129
+ // address. Arrays must NOT be narrowed as plain objects — they fall through
130
+ // to the raw-text scan, so the leak is still caught.
131
+ await seedRaw(
132
+ root,
133
+ record({ id: "arr-leak" }),
134
+ `${JSON.stringify(["contact alice.example@contoso.com for details"], null, 2)}\n`,
135
+ );
136
+
137
+ const { code, stderr } = await runCheck(root);
138
+ assert.equal(code, 1, "expected a PII violation inside a JSON array record to fail the build");
139
+ assert.match(stderr, /PII detected/);
140
+ assert.match(stderr, /email/);
141
+ assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
142
+ });
143
+
144
+ test("check:pii is a no-op (exit 0) on a root with no records/ layout", async () => {
145
+ const root = await makeRoot();
146
+ const { code, stdout } = await runCheck(root);
147
+ assert.equal(code, 0);
148
+ assert.match(stdout, /0 record\(s\) scanned/);
149
+ });
150
+
151
+ test("check:pii does not follow a symlink record into a target outside the root", async () => {
152
+ const root = await makeRoot();
153
+ // A secret file OUTSIDE the substrate root (e.g. a CI secret the scanner
154
+ // should never touch), carrying an email address.
155
+ const secret = join(await makeRoot(), "ci-secret.txt");
156
+ await writeFile(secret, "token owner: alice.example@contoso.com\n", "utf8");
157
+ // A symlink placed at a canonical record path that points at that secret. The
158
+ // scanner must reject non-regular files (symlinks) up front — otherwise a
159
+ // crafted `records/**.json` symlink would make it read and classify content
160
+ // outside the intended substrate root.
161
+ const rel = recordRelativePath(record({ id: "evil-link" }));
162
+ const linkPath = join(root, rel);
163
+ await mkdir(dirname(linkPath), { recursive: true });
164
+ await symlink(secret, linkPath);
165
+
166
+ const { code, stdout } = await runCheck(root);
167
+ assert.equal(code, 0, `symlinked record must be skipped, not scanned; stdout=${stdout}`);
168
+ assert.match(stdout, /0 record\(s\) scanned/);
169
+ });
170
+
171
+ test("check:pii does not follow a symlinked DIRECTORY into a target outside the root", async () => {
172
+ const root = await makeRoot();
173
+ // A directory OUTSIDE the substrate root holding a record-shaped file that
174
+ // carries an email address — content the scanner must never reach.
175
+ const outsideDir = join(await makeRoot(), "outside-bucket");
176
+ await mkdir(outsideDir, { recursive: true });
177
+ await writeFile(
178
+ join(outsideDir, "rec-leak.json"),
179
+ `${JSON.stringify(record({ id: "rec-leak", statement: "owned by alice.example@contoso.com" }), null, 2)}\n`,
180
+ "utf8",
181
+ );
182
+ // Symlink an INTERMEDIATE bucket directory in the layout to that outside dir.
183
+ // `records/epic/issue-303` → outsideDir makes `records/epic/issue-303/rec-leak.json`
184
+ // look like a canonical record path. `readdir(recursive:true)` would follow
185
+ // the symlinked directory and scan (and re-leak) the outside file; the walk
186
+ // must refuse to descend into any symlinked directory.
187
+ const bucketLink = join(root, "records", "epic", "issue-303");
188
+ await mkdir(dirname(bucketLink), { recursive: true });
189
+ await symlink(outsideDir, bucketLink);
190
+
191
+ const { code, stdout, stderr } = await runCheck(root);
192
+ assert.equal(code, 0, `symlinked bucket dir must not be traversed; stderr=${stderr}`);
193
+ assert.match(stdout, /0 record\(s\) scanned/);
194
+ // And it must never re-leak the outside PII it refused to scan.
195
+ assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
196
+ });