@nanobpm/urban 0.70.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/context/README.md +44 -0
- package/src/context/pii/check-pii.test.ts +196 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.0",
|
|
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",
|
package/src/context/README.md
CHANGED
|
@@ -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,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
|
+
});
|