@stravica-ai/digital-operator-pa 0.1.29 → 0.1.31

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.
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { createHash, randomUUID } from 'node:crypto';
15
15
  import { hostname } from 'node:os';
16
- import { relative, isAbsolute } from 'node:path';
16
+ import { isAbsolute, normalize, relative, sep } from 'node:path';
17
17
 
18
18
  import { EVENT_KINDS, canonicalJson } from '@stravica-ai/remote-library';
19
19
 
@@ -31,6 +31,47 @@ export function contentShaOf(payload) {
31
31
  return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
32
32
  }
33
33
 
34
+ /**
35
+ * Refuse a source path that would encode as an escape from the repo root.
36
+ * Root-cause cure for the poison-event wedge Stream 5 re-verify Scope C
37
+ * surfaced (w-2026-08-24-dave-025, SA14). Called by buildEnvelope after
38
+ * relPath is computed. Semantics match the server's assertPathSafe
39
+ * (spec §4 apply gate): reject empty, absolute, NUL, drive-letter,
40
+ * root-backslash, `..`-segments, and any string that normalises to a
41
+ * path starting with `..`. The error names the input sourcePath and
42
+ * repoRoot so an operator can see immediately why the encoding refused.
43
+ *
44
+ * @param {string} relPath candidate repo-relative path
45
+ * @param {string} sourcePath original input (for the error message)
46
+ * @param {string} repoRoot resolved repo root (for the error message)
47
+ * @throws {Error} with exitCode=1 if the path is unsafe
48
+ */
49
+ export function assertRepoRelativeSafe(relPath, sourcePath, repoRoot) {
50
+ const fail = (reason) => {
51
+ const err = new Error(
52
+ `refusing to encode source path outside repo root (${reason}): ` +
53
+ `sourcePath=${JSON.stringify(sourcePath)}, repoRoot=${JSON.stringify(repoRoot)}, ` +
54
+ `resolvedRelative=${JSON.stringify(relPath)}. ` +
55
+ `Move the file into the repo or stage it under a repo-resident path before calling library put.`,
56
+ );
57
+ err.exitCode = 1;
58
+ throw err;
59
+ };
60
+ if (typeof relPath !== 'string') fail('path must be a string');
61
+ if (relPath.length === 0) fail('path resolves empty');
62
+ if (relPath.includes('\0')) fail('path contains NUL byte');
63
+ if (isAbsolute(relPath)) fail('resolved path is absolute');
64
+ if (/^[A-Za-z]:[\\/]/.test(relPath) || relPath.startsWith('\\')) {
65
+ fail('drive-letter or root-backslash path');
66
+ }
67
+ const parts = relPath.split(/[/\\]/);
68
+ if (parts.some((seg) => seg === '..')) fail("'..' segments not permitted");
69
+ const normalised = normalize(relPath);
70
+ if (normalised === '..' || normalised.startsWith('..' + sep) || normalised.startsWith('../')) {
71
+ fail('path normalises outside repo root');
72
+ }
73
+ }
74
+
34
75
  /**
35
76
  * Guess the event kind from a staging file path when the operator did
36
77
  * not supply one via `--kind`. Falls back to
@@ -49,21 +90,37 @@ export function guessKindFromPath(sourcePath) {
49
90
  }
50
91
 
51
92
  /**
52
- * Build a `knowledge.observation.staged` envelope for a staging file.
53
- * The payload carries `path` (destination hint — the worker's apply
54
- * function decides the actual write path) and the raw markdown body.
55
- * `frontmatter` is left absent; the worker parses it if present in the
56
- * body.
93
+ * Build an EventInput envelope for one file.
94
+ *
95
+ * The envelope construction is uniform across every kind (clientEventId,
96
+ * occurredAt, producer, tier, provenance, contentSha). Only the
97
+ * `payload` sub-object is per-kind: `knowledge.doc.delete` carries
98
+ * `{path, source?}` (deletion — no content field; the ingest schema
99
+ * refuses `content` as an additionalProperty for this kind, spec §3.1 +
100
+ * `remote-library` `src/schema/payloads.ts`), while every other kind
101
+ * currently ships as `{path, content}` (content-bearing). This mapping
102
+ * is intentionally narrow: only the delete branch differs. Every
103
+ * non-delete kind's payload shape is preserved byte-for-byte from the
104
+ * pre-cure encoding, so nothing else can regress (asserted by the
105
+ * envelope regression test).
106
+ *
107
+ * For the `knowledge.observation.staged` default the payload carries
108
+ * `path` (destination hint — the worker's apply function decides the
109
+ * actual write path) and the raw markdown body. `frontmatter` is left
110
+ * absent; the worker parses it if present in the body.
57
111
  *
58
112
  * @param {object} args
59
113
  * @param {string} args.repoRoot
60
114
  * @param {string} args.sourcePath absolute or repo-relative
61
- * @param {string} args.content
115
+ * @param {string} [args.content] ignored for `knowledge.doc.delete`
62
116
  * @param {object} args.producer { operator, agent, host?, session?, subkeyId }
63
117
  * @param {string} [args.kind] overrides guess; must be in KNOWN_KINDS
64
118
  * @param {string} [args.tier] default "auto" sentinel (spec §2.3)
65
119
  * @param {'staging'|'flush'|'role-output'|'manual'|'synthesis'} [args.sourceKind]
66
120
  * default "manual"; `library flush` passes "flush"
121
+ * @param {string} [args.source] optional routing hint; only encoded
122
+ * into the payload for `knowledge.doc.delete` (mirrors the schema's
123
+ * optional `source` field for the delete kind — spec §3.1 shape).
67
124
  * @param {() => Date} [args.clock] test seam
68
125
  * @returns {object} EventInput ready for POST /events
69
126
  */
@@ -75,11 +132,25 @@ export function buildEnvelope({
75
132
  kind,
76
133
  tier = 'auto',
77
134
  sourceKind = 'manual',
135
+ source,
78
136
  clock = () => new Date(),
79
137
  }) {
138
+ // Repo-relative resolution + refusal for anything that would encode as
139
+ // a `..`-segment or otherwise escape the repo root. This is the root
140
+ // cause cure for the Stream 5 re-verify Scope C poison-event wedge
141
+ // (w-2026-08-24-dave-025). Before this cure `relative(repoRoot, abs)`
142
+ // silently produced `../../etc/...` for files sitting outside the
143
+ // repo and the envelope was posted with a payload path the worker's
144
+ // apply gate would later refuse — halting single-writer propagation
145
+ // for the whole estate. Refusing here (client-side, at encoding time)
146
+ // stops the poison at source; SA14's ingest-side check is the second
147
+ // line; the worker's apply gate remains the third.
80
148
  const relPath = sourcePath
81
149
  ? isAbsolute(sourcePath) ? relative(repoRoot, sourcePath) : sourcePath
82
150
  : undefined;
151
+ if (relPath !== undefined) {
152
+ assertRepoRelativeSafe(relPath, sourcePath, repoRoot);
153
+ }
83
154
 
84
155
  const resolvedKind = kind ?? guessKindFromPath(relPath ?? sourcePath ?? '') ?? null;
85
156
  if (!resolvedKind) {
@@ -98,10 +169,27 @@ export function buildEnvelope({
98
169
  }
99
170
 
100
171
  const now = clock();
101
- const payload = {
102
- path: relPath ?? null,
103
- content,
104
- };
172
+ // Per-kind payload construction (spec §3.1 + `remote-library` payload
173
+ // schema). Fix Wave E (w-2026-08-24-dave-029): before this branch the
174
+ // envelope encoded `{path, content}` for every kind, and the ingest
175
+ // schema for `knowledge.doc.delete` refused the `content` key with
176
+ // 422 (additionalProperties:false, required=["path"]). Result: the
177
+ // delete verb structurally could not succeed via the CLI — the same
178
+ // deferred-defect class the retirement programme is here to close.
179
+ // The delete branch omits `content` and (optionally) attaches
180
+ // `source`. Every other kind keeps the pre-cure `{path, content}`
181
+ // shape byte-for-byte; the regression test in envelope.test.mjs
182
+ // asserts that determinism against a frozen contentSha.
183
+ const payload =
184
+ resolvedKind === 'knowledge.doc.delete'
185
+ ? {
186
+ path: relPath ?? null,
187
+ ...(source !== undefined ? { source } : {}),
188
+ }
189
+ : {
190
+ path: relPath ?? null,
191
+ content,
192
+ };
105
193
 
106
194
  const envelope = {
107
195
  clientEventId: randomUUID(),
@@ -26,7 +26,14 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
26
26
  log('usage: library put <path> [--kind <kind>] [--tier auto|<tier>]');
27
27
  return 1;
28
28
  }
29
- const content = await readFile(path, 'utf8');
29
+ // Delete events carry no content by spec (§3.1 payload schema); the
30
+ // operator can and typically will have removed the file locally before
31
+ // asking the tier remote to catch up. Reading a file that does not
32
+ // exist would be a spurious ENOENT — so we skip the read entirely for
33
+ // delete. Every other kind still reads the file to populate
34
+ // payload.content. Fix Wave E (w-2026-08-24-dave-029) partner cure to
35
+ // the envelope kind-aware branch.
36
+ const content = kind === 'knowledge.doc.delete' ? undefined : await readFile(path, 'utf8');
30
37
 
31
38
  const producer = producerFromPreferences(config);
32
39
  const envelope = buildEnvelope({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",