knosky 0.6.3 → 0.7.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/CHANGELOG.md +149 -93
- package/CREDITS.md +14 -14
- package/LICENSE.md +76 -76
- package/LIMITATIONS.md +33 -23
- package/PRIVACY.md +30 -30
- package/README.md +170 -117
- package/SECURITY.md +78 -46
- package/action/post-comment.mjs +94 -89
- package/action.yml +62 -62
- package/bin/knosky.mjs +279 -105
- package/core/CONTRACT.md +70 -70
- package/core/append-only-checkpoint.mjs +215 -0
- package/core/audit-writer.mjs +317 -0
- package/core/benchmark-results.mjs +225 -225
- package/core/bundle.mjs +178 -178
- package/core/churn.mjs +23 -23
- package/core/ci.mjs +268 -268
- package/core/comparison.mjs +189 -189
- package/core/config.mjs +189 -189
- package/core/constants.mjs +13 -13
- package/core/contract.mjs +123 -123
- package/core/cross-repo.mjs +111 -111
- package/core/decision-codes.mjs +92 -0
- package/core/destination.mjs +161 -161
- package/core/district-classification.mjs +111 -0
- package/core/doctor-scorecard.mjs +369 -0
- package/core/domain-store.mjs +347 -0
- package/core/edges.mjs +43 -43
- package/core/escalate.mjs +68 -68
- package/core/freshness.mjs +198 -194
- package/core/fs-indexer.mjs +218 -218
- package/core/key-store.mjs +348 -348
- package/core/layout.mjs +46 -46
- package/core/ledger.mjs +176 -141
- package/core/local-ipc-identity.mjs +500 -0
- package/core/lod.mjs +155 -155
- package/core/mode-b.mjs +410 -0
- package/core/multi-model-benchmark.mjs +405 -405
- package/core/net-lockdown.mjs +421 -0
- package/core/onboarding.mjs +223 -223
- package/core/operator-auth.mjs +317 -0
- package/core/overlays.mjs +45 -45
- package/core/policy-lattice.mjs +142 -0
- package/core/pr-comment.mjs +198 -198
- package/core/protocol-spec.mjs +460 -460
- package/core/provenance.mjs +320 -0
- package/core/retrieve.mjs +63 -63
- package/core/route.mjs +304 -304
- package/core/schema.mjs +275 -275
- package/core/signing-tiers.mjs +1265 -0
- package/core/swarm-bench.mjs +106 -0
- package/core/swarm-coordinator.mjs +867 -0
- package/core/trust-root-rekey.mjs +410 -0
- package/mcp/server.mjs +264 -108
- package/package.json +56 -46
- package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
- package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
- package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
- package/renderer/art/kenney/sheet_allCars.xml +545 -545
- package/renderer/build-rich.mjs +43 -43
- package/renderer/city.template.html +808 -808
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- package/ssot/tool-menu.json +130 -0
package/core/CONTRACT.md
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
# Knowledge City — Data Contract v2 (the shared spine)
|
|
2
|
-
|
|
3
|
-
> Phase 1a deliverable (SAT-385 → build). The one schema both faces (City + MCP) and every source
|
|
4
|
-
> (fs/github/board) depend on. Generalizes the fixed-4-district v1 → **N categories**. Pointers +
|
|
5
|
-
> projections ONLY — never file bodies (D-146). Council fixes baked in: a serialization **allowlist** + a
|
|
6
|
-
> best-effort **denylist scrub**. Proven to represent the current live city losslessly.
|
|
7
|
-
|
|
8
|
-
## Envelope
|
|
9
|
-
```jsonc
|
|
10
|
-
{
|
|
11
|
-
"schema_version": "2.0",
|
|
12
|
-
"generated_at": "<ISO8601>",
|
|
13
|
-
"source": { "kind": "fs|github|board|legacy", "ref": "<path/repo>", "rev": "<commit/rev>" },
|
|
14
|
-
"categories": [ { "id": "technical", "label": "Technical", "color": "#4f8cff", "order": 0 } ],
|
|
15
|
-
"node_count": 150,
|
|
16
|
-
"nodes": [ /* Node[] */ ]
|
|
17
|
-
}
|
|
18
|
-
```
|
|
19
|
-
`categories[]` is the **N-category manifest** that replaces the hardcoded 4 districts — the renderer derives
|
|
20
|
-
its districts/colors from this, so the board scales to however many categories a source has.
|
|
21
|
-
|
|
22
|
-
## Node
|
|
23
|
-
```jsonc
|
|
24
|
-
{
|
|
25
|
-
"id": "decision:D-146", // stable, source-derived
|
|
26
|
-
"kind": "decision|spec|file|dir|doc|...",
|
|
27
|
-
"title": "Index, not copy", // projection
|
|
28
|
-
"summary": "<= 200 chars", // projection (excerpt, never full body)
|
|
29
|
-
"category": "governance", // category id (was "district")
|
|
30
|
-
"status": "locked", // optional
|
|
31
|
-
"fact_date": "2026-06-19", // optional
|
|
32
|
-
"tags": [], // optional projection
|
|
33
|
-
"headings": [], // optional projection (for nav/search)
|
|
34
|
-
"links": ["decision:D-147"], // edges to other node ids
|
|
35
|
-
"provenance": { "store": "...", "ref": "...", "source_rev": "...", "fetched_at": "..." },
|
|
36
|
-
"visibility": "internal|public",
|
|
37
|
-
"sensitive": false // scrub/flag marker
|
|
38
|
-
}
|
|
39
|
-
```
|
|
40
|
-
Required: `id, kind, title, category, links, provenance`. Provenance required: `store, ref`.
|
|
41
|
-
|
|
42
|
-
## Serialization allowlist (council fix)
|
|
43
|
-
Only these node fields may **ever** be written into the index:
|
|
44
|
-
`id, kind, title, summary, category, status, fact_date, tags, headings, links, provenance, visibility, sensitive`.
|
|
45
|
-
The serializer drops anything else and the validator **fails** on any non-allowlisted field — this is the
|
|
46
|
-
structural guard against a file `body` ever leaking into the index.
|
|
47
|
-
|
|
48
|
-
## PII scrub (best-effort — NOT a security boundary)
|
|
49
|
-
Applied to every emitted text projection (title/summary/headings/tags): redact emails, `key|secret|token|password|bearer` assignments, AWS access-key IDs, private-key headers → `[REDACTED]`. The indexer also skips
|
|
50
|
-
`.git`, `node_modules`, `secrets/`, `keys/`, `.env*`, `dist/` by default, **plus** the repo's `.gitignore`
|
|
51
|
-
and a user `.kcignore`. Honest framing (per council): this reduces accidental leakage; it is not a guarantee,
|
|
52
|
-
and the local index is readable by the user's own assistant. First-run note will say so.
|
|
53
|
-
|
|
54
|
-
## Projection limits
|
|
55
|
-
`summary` ≤ 200 chars. No field carries a full file body. Full content is reached only by dereferencing
|
|
56
|
-
`provenance` to the live source (which is what keeps the city fresh — "a map, not a copy").
|
|
57
|
-
|
|
58
|
-
## Proof (this Phase-1a increment)
|
|
59
|
-
Adapting the current live city (`golden/city-data.golden.json`) through `adaptLegacy()` → contract v2:
|
|
60
|
-
- nodes **150 → 150** (no loss), edges **769 → 769** (no loss)
|
|
61
|
-
- categories derived: `product#0, technical#1, governance#2, design#3`
|
|
62
|
-
- `validateCity()` → **VALID**; scrub positive+negative tests **pass**
|
|
63
|
-
- reference instance written to `golden/city-data.v2.json`
|
|
64
|
-
|
|
65
|
-
Implementation: `core/contract.mjs` (schema + allowlist + scrub + validator + legacy adapter).
|
|
66
|
-
Reproduce: `node core/validate-golden.mjs`.
|
|
67
|
-
|
|
68
|
-
## Open at G1 (founder call)
|
|
69
|
-
None on the schema itself (it's proven). Carry-forward decision (Phase 3, not blocking): the **activation
|
|
70
|
-
layer** — council unanimously recommends deferring it to the monetization tier so v1 stays fully offline.
|
|
1
|
+
# Knowledge City — Data Contract v2 (the shared spine)
|
|
2
|
+
|
|
3
|
+
> Phase 1a deliverable (SAT-385 → build). The one schema both faces (City + MCP) and every source
|
|
4
|
+
> (fs/github/board) depend on. Generalizes the fixed-4-district v1 → **N categories**. Pointers +
|
|
5
|
+
> projections ONLY — never file bodies (D-146). Council fixes baked in: a serialization **allowlist** + a
|
|
6
|
+
> best-effort **denylist scrub**. Proven to represent the current live city losslessly.
|
|
7
|
+
|
|
8
|
+
## Envelope
|
|
9
|
+
```jsonc
|
|
10
|
+
{
|
|
11
|
+
"schema_version": "2.0",
|
|
12
|
+
"generated_at": "<ISO8601>",
|
|
13
|
+
"source": { "kind": "fs|github|board|legacy", "ref": "<path/repo>", "rev": "<commit/rev>" },
|
|
14
|
+
"categories": [ { "id": "technical", "label": "Technical", "color": "#4f8cff", "order": 0 } ],
|
|
15
|
+
"node_count": 150,
|
|
16
|
+
"nodes": [ /* Node[] */ ]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
`categories[]` is the **N-category manifest** that replaces the hardcoded 4 districts — the renderer derives
|
|
20
|
+
its districts/colors from this, so the board scales to however many categories a source has.
|
|
21
|
+
|
|
22
|
+
## Node
|
|
23
|
+
```jsonc
|
|
24
|
+
{
|
|
25
|
+
"id": "decision:D-146", // stable, source-derived
|
|
26
|
+
"kind": "decision|spec|file|dir|doc|...",
|
|
27
|
+
"title": "Index, not copy", // projection
|
|
28
|
+
"summary": "<= 200 chars", // projection (excerpt, never full body)
|
|
29
|
+
"category": "governance", // category id (was "district")
|
|
30
|
+
"status": "locked", // optional
|
|
31
|
+
"fact_date": "2026-06-19", // optional
|
|
32
|
+
"tags": [], // optional projection
|
|
33
|
+
"headings": [], // optional projection (for nav/search)
|
|
34
|
+
"links": ["decision:D-147"], // edges to other node ids
|
|
35
|
+
"provenance": { "store": "...", "ref": "...", "source_rev": "...", "fetched_at": "..." },
|
|
36
|
+
"visibility": "internal|public",
|
|
37
|
+
"sensitive": false // scrub/flag marker
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
Required: `id, kind, title, category, links, provenance`. Provenance required: `store, ref`.
|
|
41
|
+
|
|
42
|
+
## Serialization allowlist (council fix)
|
|
43
|
+
Only these node fields may **ever** be written into the index:
|
|
44
|
+
`id, kind, title, summary, category, status, fact_date, tags, headings, links, provenance, visibility, sensitive`.
|
|
45
|
+
The serializer drops anything else and the validator **fails** on any non-allowlisted field — this is the
|
|
46
|
+
structural guard against a file `body` ever leaking into the index.
|
|
47
|
+
|
|
48
|
+
## PII scrub (best-effort — NOT a security boundary)
|
|
49
|
+
Applied to every emitted text projection (title/summary/headings/tags): redact emails, `key|secret|token|password|bearer` assignments, AWS access-key IDs, private-key headers → `[REDACTED]`. The indexer also skips
|
|
50
|
+
`.git`, `node_modules`, `secrets/`, `keys/`, `.env*`, `dist/` by default, **plus** the repo's `.gitignore`
|
|
51
|
+
and a user `.kcignore`. Honest framing (per council): this reduces accidental leakage; it is not a guarantee,
|
|
52
|
+
and the local index is readable by the user's own assistant. First-run note will say so.
|
|
53
|
+
|
|
54
|
+
## Projection limits
|
|
55
|
+
`summary` ≤ 200 chars. No field carries a full file body. Full content is reached only by dereferencing
|
|
56
|
+
`provenance` to the live source (which is what keeps the city fresh — "a map, not a copy").
|
|
57
|
+
|
|
58
|
+
## Proof (this Phase-1a increment)
|
|
59
|
+
Adapting the current live city (`golden/city-data.golden.json`) through `adaptLegacy()` → contract v2:
|
|
60
|
+
- nodes **150 → 150** (no loss), edges **769 → 769** (no loss)
|
|
61
|
+
- categories derived: `product#0, technical#1, governance#2, design#3`
|
|
62
|
+
- `validateCity()` → **VALID**; scrub positive+negative tests **pass**
|
|
63
|
+
- reference instance written to `golden/city-data.v2.json`
|
|
64
|
+
|
|
65
|
+
Implementation: `core/contract.mjs` (schema + allowlist + scrub + validator + legacy adapter).
|
|
66
|
+
Reproduce: `node core/validate-golden.mjs`.
|
|
67
|
+
|
|
68
|
+
## Open at G1 (founder call)
|
|
69
|
+
None on the schema itself (it's proven). Carry-forward decision (Phase 3, not blocking): the **activation
|
|
70
|
+
layer** — council unanimously recommends deferring it to the monetization tier so v1 stays fully offline.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// KnoSky F0.2 — Append-only checkpoint (SAT-546).
|
|
2
|
+
//
|
|
3
|
+
// Writes every ledger event to a secondary JSONL checkpoint file that the
|
|
4
|
+
// evaluator process can only append to — it cannot delete or overwrite earlier
|
|
5
|
+
// records. This gives a second source of truth that a privileged local user
|
|
6
|
+
// would need to destroy to tamper with the record.
|
|
7
|
+
//
|
|
8
|
+
// OS-level append-only protection (best-effort, silently skipped when the
|
|
9
|
+
// operation is not available or the caller lacks privilege):
|
|
10
|
+
//
|
|
11
|
+
// Linux : chattr +a (requires CAP_LINUX_IMMUTABLE; typically needs root
|
|
12
|
+
// or sudo, but conferred when the daemon is started with elevated
|
|
13
|
+
// rights). Only sets the attribute — never reads back to verify,
|
|
14
|
+
// so the evaluator itself never needs elevated rights after the
|
|
15
|
+
// initial chattr call.
|
|
16
|
+
// macOS : chflags uappnd (user-owned append-only flag; can be set by the
|
|
17
|
+
// file's owner without root).
|
|
18
|
+
//
|
|
19
|
+
// The module NEVER attempts to *remove* the attribute — that would require
|
|
20
|
+
// the same elevated rights and would defeat the purpose.
|
|
21
|
+
//
|
|
22
|
+
// The evaluator calls `appendCheckpointEntry(path, entry)` on every ledger
|
|
23
|
+
// write via the optional `checkpointPath` parameter added to `checkAndAdvance`
|
|
24
|
+
// (core/ledger.mjs) in SAT-561. It uses
|
|
25
|
+
// `O_WRONLY | O_APPEND | O_CREAT` (no truncation flag) so every open is an
|
|
26
|
+
// append even without the OS attribute. The OS attribute provides the
|
|
27
|
+
// additional guarantee that a privileged process using O_WRONLY without
|
|
28
|
+
// O_APPEND (or `unlink`) is also blocked.
|
|
29
|
+
//
|
|
30
|
+
// SEPARATION OF CONCERNS (ticket round-3 clarification; PR #62 review, round
|
|
31
|
+
// 5: hardened from a directory-level convention into a PACKAGE-BOUNDARY
|
|
32
|
+
// fact -- the export daemon is not just a different folder, it is a
|
|
33
|
+
// completely separate package/repo this repo has zero dependency on):
|
|
34
|
+
// - This module is the ONLY checkpoint surface the evaluator touches.
|
|
35
|
+
// - Any export to a remote destination is handled by the separate
|
|
36
|
+
// knosky-export-daemon package (github.com/SathiaAI/knosky-export-daemon)
|
|
37
|
+
// -- an unsandboxed, opt-in daemon an organization must deliberately
|
|
38
|
+
// install; this repo never imports, requires, or ships any part of it,
|
|
39
|
+
// and gains no network capability in any configuration regardless of
|
|
40
|
+
// whether that package is installed elsewhere.
|
|
41
|
+
//
|
|
42
|
+
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
openSync, appendFileSync, closeSync,
|
|
46
|
+
mkdirSync, existsSync,
|
|
47
|
+
} from 'node:fs';
|
|
48
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
49
|
+
import { spawnSync } from 'node:child_process';
|
|
50
|
+
import { platform } from 'node:os';
|
|
51
|
+
|
|
52
|
+
// Platform string, cached at module load.
|
|
53
|
+
const PLATFORM = platform();
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// setAppendOnlyAttribute — best-effort OS attribute
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Attempt to set the OS append-only attribute on `filePath`.
|
|
61
|
+
*
|
|
62
|
+
* Returns a structured result so callers can log the outcome without the
|
|
63
|
+
* evaluator needing to catch exceptions. The attribute is advisory — failure
|
|
64
|
+
* does NOT prevent writes via `appendCheckpointEntry`; the O_APPEND open flag
|
|
65
|
+
* already ensures append semantics at the process level.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} filePath Absolute path to the checkpoint file.
|
|
68
|
+
* @returns {{ ok: boolean, mechanism: string|null, skipped: boolean, error: string|null }}
|
|
69
|
+
*/
|
|
70
|
+
export function setAppendOnlyAttribute(filePath) {
|
|
71
|
+
// PR #62 round-7 fix: enforce the documented contract (filePath must be an
|
|
72
|
+
// absolute path) before this reaches a privileged child process at all --
|
|
73
|
+
// rejects relative paths, empty strings, and non-string input up front.
|
|
74
|
+
if (typeof filePath !== 'string' || filePath.length === 0 || !isAbsolute(filePath)) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
mechanism: null,
|
|
78
|
+
skipped: true,
|
|
79
|
+
error: 'invalid filePath: must be a non-empty absolute path',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// PR #62 round-7 fix: distinguish signal-based termination (process was
|
|
84
|
+
// killed) from a plain non-zero exit, so operators can tell them apart
|
|
85
|
+
// instead of a single generic error message.
|
|
86
|
+
const describeFailure = (r, cmdLabel) => {
|
|
87
|
+
if (r.error) return String(r.error.message || r.error);
|
|
88
|
+
if (r.signal !== null) return `${cmdLabel} was terminated by signal ${r.signal}`;
|
|
89
|
+
return `${cmdLabel} exited with status ${r.status}`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (PLATFORM === 'linux') {
|
|
93
|
+
// chattr +a — requires CAP_LINUX_IMMUTABLE; best-effort
|
|
94
|
+
const r = spawnSync('chattr', ['+a', filePath], {
|
|
95
|
+
stdio: 'ignore',
|
|
96
|
+
timeout: 5000,
|
|
97
|
+
});
|
|
98
|
+
if (r.error || r.status !== 0 || r.signal !== null) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
mechanism: 'chattr+a',
|
|
102
|
+
skipped: false,
|
|
103
|
+
error: describeFailure(r, 'chattr'),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, mechanism: 'chattr+a', skipped: false, error: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (PLATFORM === 'darwin') {
|
|
110
|
+
// chflags uappnd — settable by the file owner, no root needed
|
|
111
|
+
const r = spawnSync('chflags', ['uappnd', filePath], {
|
|
112
|
+
stdio: 'ignore',
|
|
113
|
+
timeout: 5000,
|
|
114
|
+
});
|
|
115
|
+
if (r.error || r.status !== 0 || r.signal !== null) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
mechanism: 'chflags-uappnd',
|
|
119
|
+
skipped: false,
|
|
120
|
+
error: describeFailure(r, 'chflags'),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, mechanism: 'chflags-uappnd', skipped: false, error: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Unsupported platform — skip silently.
|
|
127
|
+
return { ok: false, mechanism: null, skipped: true, error: null };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// openCheckpoint — create & protect the checkpoint file on first use
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Ensure the checkpoint file at `checkpointPath` exists, applying the
|
|
136
|
+
* OS append-only attribute immediately after creation.
|
|
137
|
+
*
|
|
138
|
+
* Call this once at process startup (before calling `appendCheckpointEntry`).
|
|
139
|
+
* Safe to call on a file that already exists — the OS attribute is only
|
|
140
|
+
* (re-)applied on first creation, since chattr+a/chflags-uappnd persist on
|
|
141
|
+
* the inode and do not need re-setting on every idempotent re-open.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} checkpointPath Path to the JSONL checkpoint file.
|
|
144
|
+
* @returns {{ created: boolean, attributeResult: object }}
|
|
145
|
+
* `created` is true if this call created the file (false if it pre-existed).
|
|
146
|
+
* `attributeResult` is the result of {@link setAppendOnlyAttribute}.
|
|
147
|
+
*/
|
|
148
|
+
export function openCheckpoint(checkpointPath) {
|
|
149
|
+
const dir = dirname(checkpointPath);
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
|
|
152
|
+
const created = !existsSync(checkpointPath);
|
|
153
|
+
if (created) {
|
|
154
|
+
// O_CREAT | O_WRONLY — create the empty file without truncating anything.
|
|
155
|
+
const fd = openSync(checkpointPath, 'a');
|
|
156
|
+
closeSync(fd);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// PR #62 round-7 fix: only set the OS attribute on first creation. Both
|
|
160
|
+
// chattr +a and chflags uappnd are persistent flags on the inode -- they
|
|
161
|
+
// do not need re-applying on every idempotent re-open, and doing so spawned
|
|
162
|
+
// an unnecessary child process on every call (flagged as a resource-use
|
|
163
|
+
// concern for a function that may be called repeatedly during a process's
|
|
164
|
+
// lifetime).
|
|
165
|
+
const attributeResult = created
|
|
166
|
+
? setAppendOnlyAttribute(checkpointPath)
|
|
167
|
+
: { ok: true, mechanism: null, skipped: true, error: null };
|
|
168
|
+
return { created, attributeResult };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// appendCheckpointEntry — the hot-path writer
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Append one checkpoint record to the JSONL file.
|
|
177
|
+
*
|
|
178
|
+
* Each record is written as a single JSON line followed by `\n`. The open
|
|
179
|
+
* mode is always `O_WRONLY | O_APPEND | O_CREAT` (via Node's `'a'` flag) so
|
|
180
|
+
* the process cannot overwrite or truncate the file even if the OS attribute
|
|
181
|
+
* was not successfully applied.
|
|
182
|
+
*
|
|
183
|
+
* The function is intentionally synchronous — checkpoint writes must be
|
|
184
|
+
* durable before the evaluator signals success to the caller.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} checkpointPath Path to the JSONL checkpoint file.
|
|
187
|
+
* @param {object} entry Record to append. Must be JSON-serialisable.
|
|
188
|
+
* Conventional fields:
|
|
189
|
+
* `seq` — ledger sequence number (non-negative integer).
|
|
190
|
+
* `event` — event type string (e.g. `'ledger_state'`, `'policy_eval'`).
|
|
191
|
+
* `ts` — ISO-8601 timestamp string (caller-supplied; not auto-added).
|
|
192
|
+
* `payload_hash` — SHA-256 hex of the event payload (for F2 chain linkage).
|
|
193
|
+
* @throws {TypeError} When `entry` cannot be serialised to JSON.
|
|
194
|
+
* @throws {Error} On filesystem I/O errors.
|
|
195
|
+
*/
|
|
196
|
+
export function appendCheckpointEntry(checkpointPath, entry) {
|
|
197
|
+
const line = JSON.stringify(entry) + '\n';
|
|
198
|
+
// 'a' flag = O_WRONLY | O_APPEND | O_CREAT — never truncates.
|
|
199
|
+
appendFileSync(checkpointPath, line, { encoding: 'utf8', flag: 'a' });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Export-daemon isolation sentinel
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Symbolic constant confirming that this module (the evaluator-side checkpoint
|
|
208
|
+
* writer) is intentionally separate from the export daemon.
|
|
209
|
+
*
|
|
210
|
+
* Any code path that imports both this module AND `core/export-daemon.mjs`
|
|
211
|
+
* within the evaluator process is a design violation — the evaluator must
|
|
212
|
+
* never gain network capability. Tests import this constant to assert the
|
|
213
|
+
* architectural separation.
|
|
214
|
+
*/
|
|
215
|
+
export const EVALUATOR_NO_NETWORK_SENTINEL = 'f02_evaluator_local_only';
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// KnoSky Mode B audit receipt writer (DEC-106).
|
|
2
|
+
// Metadata-only hash-chained local ledger. No file bodies. No network.
|
|
3
|
+
// Best-effort exclusive seq.lock reduces concurrent multi-agent TOCTOU on ledger_seq.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
appendFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
openSync,
|
|
13
|
+
closeSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { readHwm } from './ledger.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} domainRoot
|
|
22
|
+
*/
|
|
23
|
+
export function auditPaths(domainRoot) {
|
|
24
|
+
return {
|
|
25
|
+
root: domainRoot,
|
|
26
|
+
eventsPath: join(domainRoot, 'audit', 'events.ndjson'),
|
|
27
|
+
hwmPath: join(domainRoot, 'audit', 'hwm.json'),
|
|
28
|
+
checkpointPath: join(domainRoot, 'audit', 'checkpoint.ndjson'),
|
|
29
|
+
seqLockPath: join(domainRoot, 'audit', 'seq.lock'),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hashLine(prevHash, bodyObj) {
|
|
34
|
+
const h = createHash('sha256');
|
|
35
|
+
h.update(prevHash || '');
|
|
36
|
+
h.update('\n');
|
|
37
|
+
h.update(JSON.stringify(bodyObj));
|
|
38
|
+
return h.digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function lastHash(eventsPath) {
|
|
42
|
+
if (!existsSync(eventsPath)) return null;
|
|
43
|
+
const text = readFileSync(eventsPath, 'utf8').trim();
|
|
44
|
+
if (!text) return null;
|
|
45
|
+
const lines = text.split(/\r?\n/);
|
|
46
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
47
|
+
if (!lines[i].trim()) continue;
|
|
48
|
+
try {
|
|
49
|
+
const row = JSON.parse(lines[i]);
|
|
50
|
+
return row.event_hash || null;
|
|
51
|
+
} catch {
|
|
52
|
+
/* keep scanning */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeHwmSoft(hwmPath, seq) {
|
|
59
|
+
if (!Number.isInteger(seq) || seq < 0) {
|
|
60
|
+
throw new TypeError(`ledger_hwm must be a non-negative integer, got: ${JSON.stringify(seq)}`);
|
|
61
|
+
}
|
|
62
|
+
const dir = dirname(hwmPath);
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
const tmp = hwmPath + '.tmp';
|
|
65
|
+
writeFileSync(tmp, JSON.stringify({ ledger_hwm: seq }) + '\n', 'utf8');
|
|
66
|
+
renameSync(tmp, hwmPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function checkAndAdvanceSoft(seq, hwmPath) {
|
|
70
|
+
if (!Number.isInteger(seq) || seq < 0) {
|
|
71
|
+
return { ok: false, error: `bad seq ${seq}` };
|
|
72
|
+
}
|
|
73
|
+
let hwm = 0;
|
|
74
|
+
try {
|
|
75
|
+
hwm = readHwm(hwmPath);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { ok: false, error: err.message || String(err) };
|
|
78
|
+
}
|
|
79
|
+
// Must strictly advance past HWM (assigned under lock after append prep).
|
|
80
|
+
if (seq <= hwm) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: `ledger sequence ${seq} must be > high-water mark ${hwm}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
writeHwmSoft(hwmPath, seq);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return { ok: false, error: err.message || String(err) };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, seq, hwm };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Exclusive lock via O_EXCL create of seq.lock.
|
|
96
|
+
* @returns {{ ok:true, release:()=>void } | { ok:false, reason:string }}
|
|
97
|
+
*/
|
|
98
|
+
function acquireSeqLock(lockPath, { tries = 50, delayMs = 8 } = {}) {
|
|
99
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
100
|
+
for (let i = 0; i < tries; i++) {
|
|
101
|
+
try {
|
|
102
|
+
const fd = openSync(lockPath, 'wx');
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
release() {
|
|
106
|
+
try {
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
} catch {
|
|
109
|
+
/* ignore */
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
unlinkSync(lockPath);
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err && (err.code === 'EEXIST' || err.code === 'EPERM')) {
|
|
120
|
+
const end = Date.now() + delayMs;
|
|
121
|
+
while (Date.now() < end) {
|
|
122
|
+
/* brief backoff */
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
return { ok: false, reason: err && err.message ? err.message : String(err) };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { ok: false, reason: 'seq_lock_timeout' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sanitizeMeta(meta) {
|
|
133
|
+
if (!meta || typeof meta !== 'object') return {};
|
|
134
|
+
const out = {};
|
|
135
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
136
|
+
if (typeof k !== 'string' || k.length > 80) continue;
|
|
137
|
+
if (v == null) continue;
|
|
138
|
+
if (typeof v === 'string') out[k] = v.slice(0, 200);
|
|
139
|
+
else if (typeof v === 'number' || typeof v === 'boolean') out[k] = v;
|
|
140
|
+
else if (Array.isArray(v)) out[k] = v.slice(0, 20).map((x) => String(x).slice(0, 80));
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Append a route/swarm decision event and return a receipt.
|
|
147
|
+
* Fail-closed on I/O / HWM / lock errors.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} opts
|
|
150
|
+
* @returns {{ ok:true, receipt_id:string, ledger_seq:number, event_hash:string }
|
|
151
|
+
* |{ ok:false, reason:string }}
|
|
152
|
+
*/
|
|
153
|
+
export function writeDecisionReceipt(opts = {}) {
|
|
154
|
+
let lock = null;
|
|
155
|
+
try {
|
|
156
|
+
const {
|
|
157
|
+
domainRoot,
|
|
158
|
+
decision_code,
|
|
159
|
+
agent_id = null,
|
|
160
|
+
principal_id = null,
|
|
161
|
+
tool = null,
|
|
162
|
+
destination = null,
|
|
163
|
+
mode = 'B',
|
|
164
|
+
meta = {},
|
|
165
|
+
} = opts;
|
|
166
|
+
if (!domainRoot || typeof domainRoot !== 'string') {
|
|
167
|
+
return { ok: false, reason: 'missing_domain_root' };
|
|
168
|
+
}
|
|
169
|
+
if (!decision_code || typeof decision_code !== 'string') {
|
|
170
|
+
return { ok: false, reason: 'missing_decision_code' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const paths = auditPaths(domainRoot);
|
|
174
|
+
mkdirSync(dirname(paths.eventsPath), { recursive: true });
|
|
175
|
+
|
|
176
|
+
lock = acquireSeqLock(paths.seqLockPath);
|
|
177
|
+
if (!lock.ok) {
|
|
178
|
+
return { ok: false, reason: `seq_lock: ${lock.reason}` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Under lock: seq = max(lineCount, hwm) + 1, then append, then HWM.
|
|
182
|
+
const prev = lastHash(paths.eventsPath);
|
|
183
|
+
let lineCount = 0;
|
|
184
|
+
if (existsSync(paths.eventsPath)) {
|
|
185
|
+
const t = readFileSync(paths.eventsPath, 'utf8');
|
|
186
|
+
lineCount = t.split(/\r?\n/).filter(Boolean).length;
|
|
187
|
+
}
|
|
188
|
+
let hwm = 0;
|
|
189
|
+
try {
|
|
190
|
+
hwm = readHwm(paths.hwmPath);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return { ok: false, reason: `hwm_read: ${err.message || err}` };
|
|
193
|
+
}
|
|
194
|
+
const seq = Math.max(lineCount, hwm) + 1;
|
|
195
|
+
|
|
196
|
+
const receipt_id = `rcpt_${randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
197
|
+
const body = {
|
|
198
|
+
v: 1,
|
|
199
|
+
receipt_id,
|
|
200
|
+
ledger_seq: seq,
|
|
201
|
+
ts: new Date().toISOString(),
|
|
202
|
+
decision_code,
|
|
203
|
+
mode,
|
|
204
|
+
agent_id,
|
|
205
|
+
principal_id,
|
|
206
|
+
tool,
|
|
207
|
+
destination: typeof destination === 'string' ? destination.slice(0, 400) : null,
|
|
208
|
+
meta: sanitizeMeta(meta),
|
|
209
|
+
prev_hash: prev,
|
|
210
|
+
};
|
|
211
|
+
const event_hash = hashLine(prev, body);
|
|
212
|
+
body.event_hash = event_hash;
|
|
213
|
+
|
|
214
|
+
// Append first so crash after HWM can't "orphan" a seq with Vorgang no event
|
|
215
|
+
// (if crash after append before HWM, next max(line,hwm)+1 still advances cleanly)
|
|
216
|
+
appendFileSync(paths.eventsPath, JSON.stringify(body) + '\n', 'utf8');
|
|
217
|
+
|
|
218
|
+
const adv = checkAndAdvanceSoft(seq, paths.hwmPath);
|
|
219
|
+
if (!adv.ok) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
reason: `hwm_or_checkpoint: ${adv.error || 'refused'}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { ok: true, receipt_id, ledger_seq: seq, event_hash };
|
|
227
|
+
} catch (err) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: err && err.message ? err.message : String(err),
|
|
231
|
+
};
|
|
232
|
+
} finally {
|
|
233
|
+
if (lock && lock.ok && typeof lock.release === 'function') {
|
|
234
|
+
lock.release();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Verify hash chain + contiguous ledger_seq.
|
|
241
|
+
* @param {string} domainRoot
|
|
242
|
+
*/
|
|
243
|
+
export function verifyAuditChain(domainRoot) {
|
|
244
|
+
const { eventsPath } = auditPaths(domainRoot);
|
|
245
|
+
if (!existsSync(eventsPath)) return { ok: true, count: 0 };
|
|
246
|
+
const lines = readFileSync(eventsPath, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
247
|
+
let prev = null;
|
|
248
|
+
let prevSeq = 0;
|
|
249
|
+
for (let i = 0; i < lines.length; i++) {
|
|
250
|
+
let row;
|
|
251
|
+
try {
|
|
252
|
+
row = JSON.parse(lines[i]);
|
|
253
|
+
} catch {
|
|
254
|
+
return { ok: false, at: i, reason: 'invalid_json' };
|
|
255
|
+
}
|
|
256
|
+
const { event_hash, ...rest } = row;
|
|
257
|
+
const body = { ...rest };
|
|
258
|
+
const recomputed = hashLine(body.prev_hash || null, body);
|
|
259
|
+
if (recomputed !== event_hash) {
|
|
260
|
+
return { ok: false, at: i, reason: 'hash_mismatch', receipt_id: row.receipt_id };
|
|
261
|
+
}
|
|
262
|
+
if (i > 0 && body.prev_hash !== prev) {
|
|
263
|
+
return { ok: false, at: i, reason: 'prev_hash_break', receipt_id: row.receipt_id };
|
|
264
|
+
}
|
|
265
|
+
if (Number.isInteger(row.ledger_seq)) {
|
|
266
|
+
if (i === 0) {
|
|
267
|
+
if (row.ledger_seq < 1) {
|
|
268
|
+
return { ok: false, at: i, reason: 'seq_invalid', receipt_id: row.receipt_id };
|
|
269
|
+
}
|
|
270
|
+
} else if (row.ledger_seq !== prevSeq + 1) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
at: i,
|
|
274
|
+
reason: 'seq_not_contiguous',
|
|
275
|
+
receipt_id: row.receipt_id,
|
|
276
|
+
expected: prevSeq + 1,
|
|
277
|
+
got: row.ledger_seq,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
prevSeq = row.ledger_seq;
|
|
281
|
+
}
|
|
282
|
+
prev = event_hash;
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, count: lines.length };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @param {string} domainRoot
|
|
289
|
+
* @param {{ limit?: number, agent_id?: string }} [opts]
|
|
290
|
+
*/
|
|
291
|
+
export function queryAudit(domainRoot, opts = {}) {
|
|
292
|
+
const { eventsPath } = auditPaths(domainRoot);
|
|
293
|
+
if (!existsSync(eventsPath)) return [];
|
|
294
|
+
const limit = Math.min(Math.max(1, opts.limit || 20), 200);
|
|
295
|
+
const lines = readFileSync(eventsPath, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
296
|
+
const rows = [];
|
|
297
|
+
for (let i = lines.length - 1; i >= 0 && rows.length < limit; i--) {
|
|
298
|
+
try {
|
|
299
|
+
const row = JSON.parse(lines[i]);
|
|
300
|
+
if (opts.agent_id && row.agent_id !== opts.agent_id) continue;
|
|
301
|
+
rows.push({
|
|
302
|
+
receipt_id: row.receipt_id,
|
|
303
|
+
ledger_seq: row.ledger_seq,
|
|
304
|
+
ts: row.ts,
|
|
305
|
+
decision_code: row.decision_code,
|
|
306
|
+
mode: row.mode,
|
|
307
|
+
agent_id: row.agent_id,
|
|
308
|
+
tool: row.tool,
|
|
309
|
+
destination: row.destination,
|
|
310
|
+
event_hash: row.event_hash,
|
|
311
|
+
});
|
|
312
|
+
} catch {
|
|
313
|
+
/* skip */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return rows;
|
|
317
|
+
}
|