@indigoai-us/hq-cloud 6.14.35 → 6.14.37

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.
@@ -45,7 +45,7 @@ vi.mock("readline", () => ({
45
45
  }));
46
46
 
47
47
  import * as readline from "readline";
48
- import { share, _testing as shareTesting } from "./share.js";
48
+ import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, _testing as shareTesting } from "./share.js";
49
49
  import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
50
50
  import type { EntityContext } from "../types.js";
51
51
  import { VaultAuthError } from "../vault-client.js";
@@ -125,6 +125,220 @@ describe("wrapFilterWithIgnoreVisibility", () => {
125
125
  });
126
126
  });
127
127
 
128
+ describe("resolveNamedPath (feedback_258e4a86 / feedback_a51cb63d)", () => {
129
+ let tmpDir: string;
130
+
131
+ beforeEach(() => {
132
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-resolve-test-"));
133
+ });
134
+
135
+ afterEach(() => {
136
+ fs.rmSync(tmpDir, { recursive: true, force: true });
137
+ });
138
+
139
+ it("returns absolute paths verbatim", () => {
140
+ const abs = path.join(tmpDir, "x.md");
141
+ expect(
142
+ shareTesting.resolveNamedPath(abs, tmpDir, path.join(tmpDir, "companies", "acme"), tmpDir),
143
+ ).toBe(abs);
144
+ });
145
+
146
+ it("resolves a company-relative path against the company folder when it does not exist under hqRoot", () => {
147
+ const syncRoot = path.join(tmpDir, "companies", "acme");
148
+ fs.mkdirSync(path.join(syncRoot, "knowledge", "agents"), { recursive: true });
149
+ const brief = path.join(syncRoot, "knowledge", "agents", "brief.md");
150
+ fs.writeFileSync(brief, "x");
151
+ // hqRoot base would be <tmpDir>/knowledge/agents/brief.md, which is absent;
152
+ // the syncRoot base resolves. This is the reporter's "company-relative
153
+ // spelling reaches nothing" case.
154
+ expect(shareTesting.resolveNamedPath("knowledge/agents/brief.md", tmpDir, syncRoot, tmpDir))
155
+ .toBe(brief);
156
+ });
157
+
158
+ it("resolves against the injected cwd when neither hqRoot nor the company folder has it", () => {
159
+ const syncRoot = path.join(tmpDir, "companies", "acme");
160
+ const workDir = path.join(tmpDir, "work");
161
+ fs.mkdirSync(workDir, { recursive: true });
162
+ fs.writeFileSync(path.join(workDir, "local.md"), "x");
163
+ // cwd is INJECTED, never read ambiently — no process.chdir in this suite.
164
+ expect(shareTesting.resolveNamedPath("local.md", tmpDir, syncRoot, workDir)).toBe(
165
+ path.join(workDir, "local.md"),
166
+ );
167
+ });
168
+
169
+ it("prefers the company folder over an hqRoot homonym outside the company tree", () => {
170
+ const syncRoot = path.join(tmpDir, "companies", "acme");
171
+ const workDir = path.join(tmpDir, "work");
172
+ for (const base of [tmpDir, syncRoot, workDir]) {
173
+ fs.mkdirSync(path.join(base, "knowledge"), { recursive: true });
174
+ fs.writeFileSync(path.join(base, "knowledge", "brief.md"), base);
175
+ }
176
+
177
+ // hqRoot/companies/acme is outside syncRoot's parent chain for tmpDir/knowledge,
178
+ // so the unrelated hq-root homonym is skipped and syncRoot wins.
179
+ expect(shareTesting.resolveNamedPath("knowledge/brief.md", tmpDir, syncRoot, workDir)).toBe(
180
+ path.join(syncRoot, "knowledge", "brief.md"),
181
+ );
182
+
183
+ // With the company copy gone, cwd is the last resort (hqRoot homonym still skipped).
184
+ fs.rmSync(path.join(syncRoot, "knowledge"), { recursive: true, force: true });
185
+ expect(shareTesting.resolveNamedPath("knowledge/brief.md", tmpDir, syncRoot, workDir)).toBe(
186
+ path.join(workDir, "knowledge", "brief.md"),
187
+ );
188
+ });
189
+
190
+ it("honors hqRoot-first precedence when the hqRoot hit is inside the company tree", () => {
191
+ const syncRoot = path.join(tmpDir, "companies", "acme");
192
+ const nested = path.join(syncRoot, "nested");
193
+ fs.mkdirSync(path.join(nested, "knowledge"), { recursive: true });
194
+ fs.writeFileSync(path.join(nested, "knowledge", "brief.md"), "nested");
195
+ fs.mkdirSync(path.join(syncRoot, "knowledge"), { recursive: true });
196
+ fs.writeFileSync(path.join(syncRoot, "knowledge", "brief.md"), "company");
197
+
198
+ expect(
199
+ shareTesting.resolveNamedPath("nested/knowledge/brief.md", syncRoot, syncRoot, syncRoot),
200
+ ).toBe(path.join(nested, "knowledge", "brief.md"));
201
+ });
202
+
203
+ it("falls back to the hqRoot base (preserving the 'does not exist' diagnostic) when nothing resolves", () => {
204
+ const syncRoot = path.join(tmpDir, "companies", "acme");
205
+ expect(shareTesting.resolveNamedPath("nope/missing.md", tmpDir, syncRoot, tmpDir)).toBe(
206
+ path.resolve(tmpDir, "nope/missing.md"),
207
+ );
208
+ });
209
+
210
+ it("defaults cwd to process.cwd() when the parameter is omitted", () => {
211
+ const syncRoot = path.join(tmpDir, "companies", "acme");
212
+ // Nothing exists under any base, so the fallback proves the signature is
213
+ // callable without the injected cwd (production call sites rely on it).
214
+ expect(shareTesting.resolveNamedPath("nope/missing.md", tmpDir, syncRoot)).toBe(
215
+ path.resolve(tmpDir, "nope/missing.md"),
216
+ );
217
+ });
218
+ });
219
+
220
+ describe("isWithinLexicalOrReal (feedback_258e4a86 / feedback_a51cb63d)", () => {
221
+ let tmpDir: string;
222
+ let hqRoot: string;
223
+
224
+ beforeEach(() => {
225
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-within-test-"));
226
+ hqRoot = path.join(tmpDir, "hq");
227
+ fs.mkdirSync(hqRoot, { recursive: true });
228
+ });
229
+
230
+ afterEach(() => {
231
+ fs.rmSync(tmpDir, { recursive: true, force: true });
232
+ });
233
+
234
+ it("accepts an in-tree path reached through a symlinked ancestor (which realpath would reject)", () => {
235
+ const syncRoot = path.join(hqRoot, "companies", "acme");
236
+ fs.mkdirSync(syncRoot, { recursive: true });
237
+ const external = path.join(hqRoot, "repos", "knowledge-acme", "agents");
238
+ fs.mkdirSync(external, { recursive: true });
239
+ fs.writeFileSync(path.join(external, "brief.md"), "x");
240
+ fs.symlinkSync(path.join(hqRoot, "repos", "knowledge-acme"), path.join(syncRoot, "knowledge"));
241
+
242
+ const throughLink = path.join(syncRoot, "knowledge", "agents", "brief.md");
243
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, throughLink, hqRoot)).toBe(true);
244
+ });
245
+
246
+ it("still rejects a path that is genuinely outside the company folder", () => {
247
+ const syncRoot = path.join(hqRoot, "companies", "acme");
248
+ fs.mkdirSync(syncRoot, { recursive: true });
249
+ const outside = path.join(hqRoot, "outside", "stray.md");
250
+ fs.mkdirSync(path.dirname(outside), { recursive: true });
251
+ fs.writeFileSync(outside, "x");
252
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, outside, hqRoot)).toBe(false);
253
+ });
254
+
255
+ it("rejects a lexically-inside path whose realpath escapes the HQ tree entirely", () => {
256
+ // The lexical arm is what lets a symlinked ancestor through; without the
257
+ // hqRoot bound it would ALSO let `companies/acme/elsewhere/secret` upload
258
+ // bytes from anywhere on the machine under a company-namespaced key.
259
+ const syncRoot = path.join(hqRoot, "companies", "acme");
260
+ fs.mkdirSync(syncRoot, { recursive: true });
261
+ const foreign = path.join(tmpDir, "not-hq", "secrets");
262
+ fs.mkdirSync(foreign, { recursive: true });
263
+ fs.writeFileSync(path.join(foreign, "creds.txt"), "x");
264
+ fs.symlinkSync(foreign, path.join(syncRoot, "elsewhere"));
265
+
266
+ const escaping = path.join(syncRoot, "elsewhere", "creds.txt");
267
+ // Lexically inside the company folder…
268
+ const lexicalRel = path.relative(syncRoot, escaping);
269
+ expect(lexicalRel.startsWith("..")).toBe(false);
270
+ // …but the realpath leaves HQ, so it is refused.
271
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, escaping, hqRoot)).toBe(false);
272
+ });
273
+
274
+ // ── Tenant bound on the lexical arm ──────────────────────────────────────
275
+ //
276
+ // Bounding the lexical arm by hqRoot alone is NOT enough: hqRoot contains
277
+ // every other company, so a symlinked ancestor under companies/acme that
278
+ // points at another tenant's bytes would pass "lexically inside acme AND
279
+ // really inside HQ" and upload those bytes into acme's vault under an
280
+ // acme-namespaced key. The bound has to be the TENANT, not the tree.
281
+ it("rejects a lexically-inside path whose realpath lands in another company's folder", () => {
282
+ const syncRoot = path.join(hqRoot, "companies", "acme");
283
+ fs.mkdirSync(syncRoot, { recursive: true });
284
+ const otherSecrets = path.join(hqRoot, "companies", "other", "secret");
285
+ fs.mkdirSync(otherSecrets, { recursive: true });
286
+ fs.writeFileSync(path.join(otherSecrets, "creds.md"), "other tenant's bytes");
287
+ // A mislinked (or hostile) ancestor pointing straight at a sibling tenant.
288
+ fs.symlinkSync(otherSecrets, path.join(syncRoot, "knowledge"));
289
+
290
+ const crossTenant = path.join(syncRoot, "knowledge", "creds.md");
291
+ // Lexically inside acme, and its realpath is inside HQ — so only the
292
+ // tenant bound can refuse it.
293
+ expect(path.relative(syncRoot, crossTenant).startsWith("..")).toBe(false);
294
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, crossTenant, hqRoot)).toBe(false);
295
+ });
296
+
297
+ it("rejects a lexically-inside path whose realpath lands in another company's linked knowledge repo", () => {
298
+ // HQ topology pattern 2: a company's knowledge lives in its own repo under
299
+ // repos/private/ and is symlinked into the company folder. That target is
300
+ // outside every companies/<slug> root, so a companies-only bound would
301
+ // miss it — the sibling company's OWN link is what marks it as claimed.
302
+ const syncRoot = path.join(hqRoot, "companies", "acme");
303
+ fs.mkdirSync(syncRoot, { recursive: true });
304
+ const otherRoot = path.join(hqRoot, "companies", "other");
305
+ fs.mkdirSync(otherRoot, { recursive: true });
306
+ const otherRepo = path.join(hqRoot, "repos", "private", "knowledge-other");
307
+ fs.mkdirSync(otherRepo, { recursive: true });
308
+ fs.writeFileSync(path.join(otherRepo, "roadmap.md"), "other tenant's roadmap");
309
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
310
+ // acme's ancestor points at the sibling's repo (a typo away from its own).
311
+ fs.symlinkSync(otherRepo, path.join(syncRoot, "knowledge"));
312
+
313
+ const crossTenant = path.join(syncRoot, "knowledge", "roadmap.md");
314
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, crossTenant, hqRoot)).toBe(false);
315
+ });
316
+
317
+ it("still accepts the company's OWN linked knowledge repo when sibling tenants exist", () => {
318
+ // The tenant bound must not collapse the motivating topology: a sibling
319
+ // company being present (and publishing its own link) is not a reason to
320
+ // refuse acme's own linked subtree.
321
+ const syncRoot = path.join(hqRoot, "companies", "acme");
322
+ fs.mkdirSync(syncRoot, { recursive: true });
323
+ const otherRoot = path.join(hqRoot, "companies", "other");
324
+ fs.mkdirSync(otherRoot, { recursive: true });
325
+ const otherRepo = path.join(hqRoot, "repos", "private", "knowledge-other");
326
+ fs.mkdirSync(otherRepo, { recursive: true });
327
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
328
+
329
+ const acmeRepo = path.join(hqRoot, "repos", "private", "knowledge-acme", "agents");
330
+ fs.mkdirSync(acmeRepo, { recursive: true });
331
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "x");
332
+ fs.symlinkSync(
333
+ path.join(hqRoot, "repos", "private", "knowledge-acme"),
334
+ path.join(syncRoot, "knowledge"),
335
+ );
336
+
337
+ const own = path.join(syncRoot, "knowledge", "agents", "brief.md");
338
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, own, hqRoot)).toBe(true);
339
+ });
340
+ });
341
+
128
342
  const mockEntity = {
129
343
  uid: "cmp_01ABCDEF",
130
344
  slug: "acme",
@@ -336,17 +550,42 @@ describe("share", () => {
336
550
  expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json", undefined, expect.anything());
337
551
  });
338
552
 
339
- it("skips files outside the company folder with a warning", async () => {
553
+ it("never uploads a file outside the company folder, and fails the push by default", async () => {
340
554
  const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
341
555
  // File at hqRoot, outside companies/acme/
342
556
  const outsideFile = path.join(tmpDir, "stray.md");
343
557
  fs.writeFileSync(outsideFile, "stray");
344
558
 
559
+ // The per-path warning is unchanged; what changed (feedback_a51cb63d ask
560
+ // #2) is that an explicitly named, locally-present, unreachable path is no
561
+ // longer swallowed into a green "Pushed 0 file(s)".
562
+ await expect(
563
+ share({
564
+ paths: [outsideFile],
565
+ company: "acme",
566
+ vaultConfig: mockConfig,
567
+ hqRoot: tmpDir,
568
+ }),
569
+ ).rejects.toBeInstanceOf(UnreachablePushPathsError);
570
+
571
+ expect(uploadFile).not.toHaveBeenCalled();
572
+ expect(warnSpy).toHaveBeenCalledWith(
573
+ expect.stringMatching(/outside company folder/i),
574
+ );
575
+ warnSpy.mockRestore();
576
+ });
577
+
578
+ it("skips files outside the company folder with a warning under unreachablePathPolicy: 'warn'", async () => {
579
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
580
+ const outsideFile = path.join(tmpDir, "stray.md");
581
+ fs.writeFileSync(outsideFile, "stray");
582
+
345
583
  const result = await share({
346
584
  paths: [outsideFile],
347
585
  company: "acme",
348
586
  vaultConfig: mockConfig,
349
587
  hqRoot: tmpDir,
588
+ unreachablePathPolicy: "warn",
350
589
  });
351
590
 
352
591
  expect(result.filesUploaded).toBe(0);
@@ -1735,6 +1974,7 @@ describe("share", () => {
1735
1974
  e.type === "scope-excluded" ||
1736
1975
  e.type === "scope-materialization-gap" ||
1737
1976
  e.type === "ignore-excluded" ||
1977
+ e.type === "not-shipped" ||
1738
1978
  e.type === "delete-refused-bulk-asymmetry"
1739
1979
  ) return;
1740
1980
  events.push({
@@ -2556,6 +2796,115 @@ describe("share", () => {
2556
2796
  expect(journal.files["other/also-gone.md"]).toBeDefined();
2557
2797
  });
2558
2798
 
2799
+ it("propagateDeletes: a company-relative directory spelling scopes deletes exactly like its absolute equivalent", async () => {
2800
+ // Resolver symmetry (cluster ask #2). Delete scoping resolved named paths
2801
+ // against hqRoot only, so `in-scope` — the same spelling `collectFiles`
2802
+ // now accepts for upload — resolved to <hqRoot>/in-scope, did not exist,
2803
+ // and was silently dropped from the scope. The result was a push whose
2804
+ // upload leg honored the path and whose delete leg did not.
2805
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2806
+ fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
2807
+ fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
2808
+
2809
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2810
+ fs.writeFileSync(
2811
+ journalPath,
2812
+ JSON.stringify({
2813
+ version: "1",
2814
+ lastSync: new Date().toISOString(),
2815
+ files: {
2816
+ "in-scope/gone.md": {
2817
+ hash: "h",
2818
+ size: 1,
2819
+ syncedAt: new Date().toISOString(),
2820
+ direction: "up",
2821
+ remoteEtag: "in-scope-etag",
2822
+ kind: "file",
2823
+ localDeleteIntent: deleteIntent("in-scope-etag", "h"),
2824
+ },
2825
+ "other/also-gone.md": {
2826
+ hash: "h",
2827
+ size: 1,
2828
+ syncedAt: new Date().toISOString(),
2829
+ direction: "up",
2830
+ remoteEtag: "other-etag",
2831
+ kind: "file",
2832
+ localDeleteIntent: deleteIntent("other-etag", "h"),
2833
+ },
2834
+ },
2835
+ }),
2836
+ );
2837
+
2838
+ const result = await share({
2839
+ paths: ["in-scope"], // company-relative, NOT hq-root-relative
2840
+ company: "acme",
2841
+ vaultConfig: mockConfig,
2842
+ hqRoot: tmpDir,
2843
+ skipUnchanged: true,
2844
+ propagateDeletes: true,
2845
+ propagateDeletePolicy: "owned-only",
2846
+ });
2847
+
2848
+ expect(result.filesDeleted).toBe(1);
2849
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
2850
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2851
+ expect(journal.files["in-scope/gone.md"]).toBeUndefined();
2852
+ // Scope stays exactly as narrow as the absolute spelling's — no wider.
2853
+ expect(journal.files["other/also-gone.md"]).toBeDefined();
2854
+ });
2855
+
2856
+ it("propagateDeletes: a directory reached through a symlinked ancestor anchors NO delete scope", async () => {
2857
+ // Deliberate asymmetry with the upload leg, and the reason delete scoping
2858
+ // keeps realpath containment: a linked subtree's contents are never walked
2859
+ // (they ship via their own repo), so treating it as a delete scope root
2860
+ // would make every vault object under that prefix look locally absent and
2861
+ // sweep it away. Narrower is the only safe direction here.
2862
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2863
+ fs.mkdirSync(companyRoot, { recursive: true });
2864
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme");
2865
+ fs.mkdirSync(acmeRepo, { recursive: true });
2866
+ fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
2867
+
2868
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2869
+ fs.writeFileSync(
2870
+ journalPath,
2871
+ JSON.stringify({
2872
+ version: "1",
2873
+ lastSync: new Date().toISOString(),
2874
+ files: {
2875
+ // Fully delete-eligible: current intent, matching etag, locally
2876
+ // absent. The ONLY thing standing between it and a DeleteObject is
2877
+ // that the linked directory anchors no scope — so a regression that
2878
+ // widened delete containment would fail this test loudly.
2879
+ "knowledge/agents/brief.md": {
2880
+ hash: "h",
2881
+ size: 1,
2882
+ syncedAt: new Date().toISOString(),
2883
+ direction: "up",
2884
+ remoteEtag: "brief-etag",
2885
+ kind: "file",
2886
+ localDeleteIntent: deleteIntent("brief-etag", "h"),
2887
+ },
2888
+ },
2889
+ }),
2890
+ );
2891
+
2892
+ const result = await share({
2893
+ paths: [path.join(companyRoot, "knowledge")],
2894
+ company: "acme",
2895
+ vaultConfig: mockConfig,
2896
+ hqRoot: tmpDir,
2897
+ skipUnchanged: true,
2898
+ propagateDeletes: true,
2899
+ propagateDeletePolicy: "owned-only",
2900
+ });
2901
+
2902
+ expect(result.filesDeleted).toBe(0);
2903
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
2904
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2905
+ expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
2906
+ });
2907
+
2559
2908
  it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
2560
2909
  const companyRoot = path.join(tmpDir, "companies", "acme");
2561
2910
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -4200,6 +4549,424 @@ describe("share", () => {
4200
4549
  });
4201
4550
  });
4202
4551
 
4552
+ // ── Push scope + visibility (feedback_258e4a86 / feedback_a51cb63d) ─────
4553
+ //
4554
+ // New local files under a synced subdir were invisible to every push path:
4555
+ // a company-relative path resolved only against hqRoot ("does not exist"),
4556
+ // an in-tree path reached through a symlinked ancestor was rejected as
4557
+ // "outside company folder", and a directory symlink to an external repo
4558
+ // swallowed its contents from every bucket — all while push still reported
4559
+ // "Pushed 0 file(s)". These pin the resolver + visibility fixes.
4560
+ describe("push scope + visibility (feedback_258e4a86 / feedback_a51cb63d)", () => {
4561
+ it("resolves a company-relative push path against the company folder, not just hqRoot", async () => {
4562
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4563
+ fs.mkdirSync(path.join(companyRoot, "knowledge", "agents"), { recursive: true });
4564
+ const brief = path.join(companyRoot, "knowledge", "agents", "brief.md");
4565
+ fs.writeFileSync(brief, "mission brief");
4566
+
4567
+ // Pre-fix: path.resolve(hqRoot, "knowledge/agents/brief.md") →
4568
+ // <hqRoot>/knowledge/agents/brief.md, which does not exist → the file
4569
+ // was reported "does not exist, skipping" and 0 uploaded no matter where
4570
+ // the operator stood. The company-folder base now resolves it.
4571
+ const result = await share({
4572
+ paths: ["knowledge/agents/brief.md"],
4573
+ company: "acme",
4574
+ vaultConfig: mockConfig,
4575
+ hqRoot: tmpDir,
4576
+ });
4577
+
4578
+ expect(result.filesUploaded).toBe(1);
4579
+ expect(result.unreachablePaths).toEqual([]);
4580
+ expect(uploadFile).toHaveBeenCalledWith(
4581
+ expect.anything(),
4582
+ brief,
4583
+ "knowledge/agents/brief.md",
4584
+ undefined,
4585
+ expect.anything(),
4586
+ );
4587
+ });
4588
+
4589
+ it("pushes an in-tree file reached through a symlinked ancestor (not 'outside company folder')", async () => {
4590
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4591
+ fs.mkdirSync(companyRoot, { recursive: true });
4592
+ const externalKnowledge = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
4593
+ fs.mkdirSync(externalKnowledge, { recursive: true });
4594
+ fs.writeFileSync(path.join(externalKnowledge, "brief.md"), "mission brief");
4595
+ // companies/acme/knowledge → repos/private/knowledge-acme (HQ pattern 2).
4596
+ fs.symlinkSync(
4597
+ path.join(tmpDir, "repos", "private", "knowledge-acme"),
4598
+ path.join(companyRoot, "knowledge"),
4599
+ );
4600
+
4601
+ // Explicitly name the in-tree file THROUGH the symlinked ancestor.
4602
+ // Pre-fix, isWithin's realpath resolved it to repos/... → "outside
4603
+ // company folder, skipping" and 0 uploaded, even though
4604
+ // vaultKeyForLocalPath (lexical) would key it fine. It now uploads under
4605
+ // its logical company-namespaced key.
4606
+ const result = await share({
4607
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
4608
+ company: "acme",
4609
+ vaultConfig: mockConfig,
4610
+ hqRoot: tmpDir,
4611
+ });
4612
+
4613
+ expect(result.filesUploaded).toBe(1);
4614
+ expect(result.unreachablePaths).toEqual([]);
4615
+ expect(uploadFile).toHaveBeenCalledWith(
4616
+ expect.anything(),
4617
+ expect.anything(),
4618
+ "knowledge/agents/brief.md",
4619
+ undefined,
4620
+ expect.anything(),
4621
+ );
4622
+ });
4623
+
4624
+ it("refuses a named push whose symlinked ancestor points at ANOTHER company's folder", async () => {
4625
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4626
+ fs.mkdirSync(companyRoot, { recursive: true });
4627
+ const otherSecrets = path.join(tmpDir, "companies", "other", "secret");
4628
+ fs.mkdirSync(otherSecrets, { recursive: true });
4629
+ fs.writeFileSync(path.join(otherSecrets, "creds.md"), "other tenant's bytes");
4630
+ // Mislinked (or hostile) ancestor inside acme pointing at a sibling tenant.
4631
+ fs.symlinkSync(otherSecrets, path.join(companyRoot, "knowledge"));
4632
+
4633
+ // Bounding the lexical containment arm by hqRoot alone would accept this
4634
+ // — the path is lexically inside acme and its realpath is inside HQ —
4635
+ // and upload company "other"'s bytes into acme's bucket under the
4636
+ // acme-namespaced key "knowledge/creds.md". The bound is the tenant.
4637
+ await expect(
4638
+ share({
4639
+ paths: [path.join(companyRoot, "knowledge", "creds.md")],
4640
+ company: "acme",
4641
+ vaultConfig: mockConfig,
4642
+ hqRoot: tmpDir,
4643
+ }),
4644
+ ).rejects.toBeInstanceOf(UnreachablePushPathsError);
4645
+
4646
+ expect(uploadFile).not.toHaveBeenCalled();
4647
+ });
4648
+
4649
+ it("refuses a named push whose symlinked ancestor points at another company's linked knowledge repo", async () => {
4650
+ // The realistic HQ topology: each company's knowledge is its own repo
4651
+ // under repos/private/, symlinked into the company folder. That target
4652
+ // sits outside every companies/<slug> root, so a companies-only bound
4653
+ // would let a typo'd link ship the sibling's repo into acme's vault.
4654
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4655
+ fs.mkdirSync(companyRoot, { recursive: true });
4656
+ const otherRoot = path.join(tmpDir, "companies", "other");
4657
+ fs.mkdirSync(otherRoot, { recursive: true });
4658
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
4659
+ fs.mkdirSync(otherRepo, { recursive: true });
4660
+ fs.writeFileSync(path.join(otherRepo, "roadmap.md"), "other tenant's roadmap");
4661
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
4662
+ fs.symlinkSync(otherRepo, path.join(companyRoot, "knowledge"));
4663
+
4664
+ await expect(
4665
+ share({
4666
+ paths: [path.join(companyRoot, "knowledge", "roadmap.md")],
4667
+ company: "acme",
4668
+ vaultConfig: mockConfig,
4669
+ hqRoot: tmpDir,
4670
+ }),
4671
+ ).rejects.toBeInstanceOf(UnreachablePushPathsError);
4672
+
4673
+ expect(uploadFile).not.toHaveBeenCalled();
4674
+ });
4675
+
4676
+ it("still ships the company's OWN linked knowledge repo when a sibling tenant is present", async () => {
4677
+ // Guard against over-correction: the tenant bound must not refuse the
4678
+ // motivating topology just because another company exists locally.
4679
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4680
+ fs.mkdirSync(companyRoot, { recursive: true });
4681
+ const otherRoot = path.join(tmpDir, "companies", "other");
4682
+ fs.mkdirSync(otherRoot, { recursive: true });
4683
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
4684
+ fs.mkdirSync(otherRepo, { recursive: true });
4685
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
4686
+
4687
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
4688
+ fs.mkdirSync(acmeRepo, { recursive: true });
4689
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "mission brief");
4690
+ fs.symlinkSync(
4691
+ path.join(tmpDir, "repos", "private", "knowledge-acme"),
4692
+ path.join(companyRoot, "knowledge"),
4693
+ );
4694
+
4695
+ const result = await share({
4696
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
4697
+ company: "acme",
4698
+ vaultConfig: mockConfig,
4699
+ hqRoot: tmpDir,
4700
+ });
4701
+
4702
+ expect(result.filesUploaded).toBe(1);
4703
+ expect(uploadFile).toHaveBeenCalledWith(
4704
+ expect.anything(),
4705
+ expect.anything(),
4706
+ "knowledge/agents/brief.md",
4707
+ undefined,
4708
+ expect.anything(),
4709
+ );
4710
+ });
4711
+
4712
+ it("separates own-link from foreign-link within a SINGLE push pass", async () => {
4713
+ // The tenant-root lookup is memoized for the duration of one collect
4714
+ // pass. This pins that the memo keys on the company being pushed rather
4715
+ // than leaking one path's verdict onto the next: in one invocation the
4716
+ // own linked repo ships and the sibling's linked repo does not.
4717
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4718
+ fs.mkdirSync(companyRoot, { recursive: true });
4719
+ const otherRoot = path.join(tmpDir, "companies", "other");
4720
+ fs.mkdirSync(otherRoot, { recursive: true });
4721
+
4722
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
4723
+ fs.mkdirSync(otherRepo, { recursive: true });
4724
+ fs.writeFileSync(path.join(otherRepo, "secret.md"), "other tenant bytes");
4725
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
4726
+ // acme ALSO links the sibling's repo in, under its own name.
4727
+ fs.symlinkSync(otherRepo, path.join(companyRoot, "borrowed"));
4728
+
4729
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
4730
+ fs.mkdirSync(acmeRepo, { recursive: true });
4731
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "mission brief");
4732
+ fs.symlinkSync(
4733
+ path.join(tmpDir, "repos", "private", "knowledge-acme"),
4734
+ path.join(companyRoot, "knowledge"),
4735
+ );
4736
+
4737
+ const result = await share({
4738
+ paths: [
4739
+ path.join(companyRoot, "knowledge", "agents", "brief.md"),
4740
+ path.join(companyRoot, "borrowed", "secret.md"),
4741
+ ],
4742
+ company: "acme",
4743
+ vaultConfig: mockConfig,
4744
+ hqRoot: tmpDir,
4745
+ // warn so the pass completes and both verdicts are observable at once;
4746
+ // the default 'fail' path is covered by the tests above.
4747
+ unreachablePathPolicy: "warn",
4748
+ });
4749
+
4750
+ expect(result.filesUploaded).toBe(1);
4751
+ expect(uploadFile).toHaveBeenCalledTimes(1);
4752
+ expect(uploadFile).toHaveBeenCalledWith(
4753
+ expect.anything(),
4754
+ expect.anything(),
4755
+ "knowledge/agents/brief.md",
4756
+ undefined,
4757
+ expect.anything(),
4758
+ );
4759
+ expect(result.unreachablePaths).toEqual([
4760
+ path.join(companyRoot, "borrowed", "secret.md"),
4761
+ ]);
4762
+ });
4763
+
4764
+ it("fails the push when an explicitly named path exists but is outside the company folder (not a silent 0-file success)", async () => {
4765
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4766
+ fs.mkdirSync(companyRoot, { recursive: true });
4767
+ fs.writeFileSync(path.join(companyRoot, "in-scope.md"), "shippable");
4768
+ // A real file that lives OUTSIDE the company folder entirely.
4769
+ const stray = path.join(tmpDir, "outside", "stray.md");
4770
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4771
+ fs.writeFileSync(stray, "outside content");
4772
+
4773
+ // Ask #2 of the report: "error, not warn-skip, when the named file
4774
+ // exists locally but is unreachable by the resolver". Pre-fix this
4775
+ // printed "✓ Pushed 0 file(s)" and exited 0.
4776
+ const events: Array<{ type: string; reason?: string; count?: number }> = [];
4777
+ await expect(
4778
+ share({
4779
+ paths: [stray, path.join(companyRoot, "in-scope.md")],
4780
+ company: "acme",
4781
+ vaultConfig: mockConfig,
4782
+ hqRoot: tmpDir,
4783
+ onEvent: (e) => events.push(e as { type: string; reason?: string; count?: number }),
4784
+ }),
4785
+ ).rejects.toBeInstanceOf(UnreachablePushPathsError);
4786
+
4787
+ // The failure is an ATOMIC no-op: it fires before any upload, so the
4788
+ // reachable sibling in the same invocation is not half-shipped either.
4789
+ expect(uploadFile).not.toHaveBeenCalled();
4790
+ // Still visible on the event stream before the throw.
4791
+ const notShipped = events.find(
4792
+ (e) => e.type === "not-shipped" && e.reason === "unreachable-path",
4793
+ );
4794
+ expect(notShipped).toBeDefined();
4795
+ expect(notShipped?.count).toBe(1);
4796
+ });
4797
+
4798
+ it("names the offending path and its reason on the thrown error", async () => {
4799
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4800
+ fs.mkdirSync(companyRoot, { recursive: true });
4801
+ const stray = path.join(tmpDir, "outside", "stray.md");
4802
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4803
+ fs.writeFileSync(stray, "outside content");
4804
+
4805
+ let err: unknown;
4806
+ try {
4807
+ await share({
4808
+ paths: [stray],
4809
+ company: "acme",
4810
+ vaultConfig: mockConfig,
4811
+ hqRoot: tmpDir,
4812
+ });
4813
+ } catch (e) {
4814
+ err = e;
4815
+ }
4816
+
4817
+ expect(err).toBeInstanceOf(UnreachablePushPathsError);
4818
+ const unreachable = err as UnreachablePushPathsError;
4819
+ expect(unreachable.paths).toEqual([stray]);
4820
+ expect(unreachable.reasons[stray]).toBe("outside-company");
4821
+ // The operator gets the actionable topology hint, not just a code.
4822
+ expect(unreachable.message).toContain("outside the company folder");
4823
+ });
4824
+
4825
+ it("records rather than throws for an unreachable path under unreachablePathPolicy: 'warn'", async () => {
4826
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4827
+ fs.mkdirSync(companyRoot, { recursive: true });
4828
+ const stray = path.join(tmpDir, "outside", "stray.md");
4829
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4830
+ fs.writeFileSync(stray, "outside content");
4831
+
4832
+ // The sync runner walks roots it computed itself, not operator input, so
4833
+ // it opts into the legacy warn behavior instead of failing a whole
4834
+ // multi-company sync on one odd root.
4835
+ const result = await share({
4836
+ paths: [stray],
4837
+ company: "acme",
4838
+ vaultConfig: mockConfig,
4839
+ hqRoot: tmpDir,
4840
+ unreachablePathPolicy: "warn",
4841
+ });
4842
+
4843
+ expect(result.filesUploaded).toBe(0);
4844
+ expect(result.unreachablePaths).toEqual([stray]);
4845
+ });
4846
+
4847
+ it("surfaces a named path that does not exist under any base as unreachable without failing the run", async () => {
4848
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4849
+ fs.mkdirSync(companyRoot, { recursive: true });
4850
+
4851
+ // "missing" is deliberately NOT fatal even under the default policy: a
4852
+ // multi-company push plans one leg per membership, including companies
4853
+ // whose folder was never materialized locally. Only "outside-company" —
4854
+ // the report's "exists locally but unreachable" case — fails the run.
4855
+ const result = await share({
4856
+ paths: ["knowledge/agents/nope.md"],
4857
+ company: "acme",
4858
+ vaultConfig: mockConfig,
4859
+ hqRoot: tmpDir,
4860
+ });
4861
+
4862
+ expect(result.filesUploaded).toBe(0);
4863
+ expect(result.unreachablePaths).toEqual(["knowledge/agents/nope.md"]);
4864
+ });
4865
+
4866
+ it("reports unreachable paths using the caller's original spelling verbatim", async () => {
4867
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4868
+ fs.mkdirSync(companyRoot, { recursive: true });
4869
+
4870
+ // Spelling contract: a relative token is echoed relative (never resolved
4871
+ // to one of the probe bases), so an operator can match the reported path
4872
+ // against the one they typed.
4873
+ const relative = await share({
4874
+ paths: ["knowledge/agents/nope.md"],
4875
+ company: "acme",
4876
+ vaultConfig: mockConfig,
4877
+ hqRoot: tmpDir,
4878
+ });
4879
+ expect(relative.unreachablePaths).toEqual(["knowledge/agents/nope.md"]);
4880
+
4881
+ // …and an absolute token is echoed absolute.
4882
+ const stray = path.join(tmpDir, "outside", "stray.md");
4883
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4884
+ fs.writeFileSync(stray, "outside content");
4885
+ const absolute = await share({
4886
+ paths: [stray],
4887
+ company: "acme",
4888
+ vaultConfig: mockConfig,
4889
+ hqRoot: tmpDir,
4890
+ unreachablePathPolicy: "warn",
4891
+ });
4892
+ expect(absolute.unreachablePaths).toEqual([stray]);
4893
+ });
4894
+
4895
+ it("reports a directory symlink to an external repo as a linked subtree not shipped to the vault", async () => {
4896
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4897
+ fs.mkdirSync(companyRoot, { recursive: true });
4898
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
4899
+ fs.mkdirSync(externalTarget, { recursive: true });
4900
+ fs.writeFileSync(path.join(externalTarget, "brief.md"), "lives in the repo");
4901
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
4902
+
4903
+ const events: Array<{ type: string; reason?: string; samplePaths?: string[] }> = [];
4904
+ const result = await share({
4905
+ paths: [companyRoot],
4906
+ company: "acme",
4907
+ vaultConfig: mockConfig,
4908
+ hqRoot: tmpDir,
4909
+ onEvent: (e) =>
4910
+ events.push(e as { type: string; reason?: string; samplePaths?: string[] }),
4911
+ });
4912
+
4913
+ // Topology contract preserved: only the link record ships; its external
4914
+ // contents do NOT (no regular-file upload for brief.md).
4915
+ expect(result.filesUploaded).toBe(1);
4916
+ expect(uploadFile).not.toHaveBeenCalled();
4917
+ // But the subtree is now VISIBLE instead of vanishing from every bucket.
4918
+ expect(result.linkedSubtreesNotShipped).toEqual(["knowledge"]);
4919
+ const notShipped = events.find(
4920
+ (e) => e.type === "not-shipped" && e.reason === "linked-subtree",
4921
+ );
4922
+ expect(notShipped).toBeDefined();
4923
+ expect(notShipped?.samplePaths).toEqual(["knowledge"]);
4924
+ });
4925
+
4926
+ it("keeps a file explicitly pushed from under a linked subtree alive across a later full-walk push", async () => {
4927
+ // The documented workaround for a linked subtree is `hq sync push
4928
+ // <path>` — a point-in-time copy. That copy is only useful if the very
4929
+ // next full-company sync does not immediately delete it again: the walk
4930
+ // never descends the link, so the key has no walk-visible counterpart.
4931
+ // Delete candidacy is lstat-based (the file IS on disk through the
4932
+ // link), so the copy survives even the gate-free "all" policy.
4933
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4934
+ fs.mkdirSync(companyRoot, { recursive: true });
4935
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
4936
+ fs.mkdirSync(path.join(externalTarget, "agents"), { recursive: true });
4937
+ fs.writeFileSync(path.join(externalTarget, "agents", "brief.md"), "mission brief");
4938
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
4939
+
4940
+ const pushed = await share({
4941
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
4942
+ company: "acme",
4943
+ vaultConfig: mockConfig,
4944
+ hqRoot: tmpDir,
4945
+ });
4946
+ expect(pushed.filesUploaded).toBe(1);
4947
+
4948
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
4949
+ const afterPush = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
4950
+ expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
4951
+
4952
+ vi.mocked(deleteRemoteFile).mockClear();
4953
+ const walked = await share({
4954
+ paths: [companyRoot],
4955
+ company: "acme",
4956
+ vaultConfig: mockConfig,
4957
+ hqRoot: tmpDir,
4958
+ propagateDeletes: true,
4959
+ propagateDeletePolicy: "all",
4960
+ });
4961
+
4962
+ expect(walked.filesDeleted).toBe(0);
4963
+ const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1] as string);
4964
+ expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
4965
+ const afterWalk = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
4966
+ expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
4967
+ });
4968
+ });
4969
+
4203
4970
  // ── Bulk-asymmetry circuit-breaker ─────────────────────────────────────
4204
4971
  //
4205
4972
  // Defends against the "local mirror lost, journal still full" failure
@@ -5646,6 +6413,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
5646
6413
  });
5647
6414
  });
5648
6415
 
6416
+
5649
6417
  describe("scope-invalid key hardening on push (doubled companies/ tree — incident 2026-07-11)", () => {
5650
6418
  let tmpDir: string;
5651
6419
  let stateDir: string;
@@ -5782,3 +6550,27 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
5782
6550
  expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
5783
6551
  });
5784
6552
  });
6553
+
6554
+ // ── Pure-function unit coverage: isForbiddenCompanyVaultKey ───────────────────
6555
+ //
6556
+ // Contract: in a COMPANY vault (personalMode=false) any key under `companies/`
6557
+ // is a doubly-scoped corrupt object and must be refused; in a PERSONAL vault
6558
+ // (personalMode=true) `companies/` keys are legitimate and handled by the
6559
+ // dedicated personalMode branch, so this guard must NOT fire there.
6560
+ describe("isForbiddenCompanyVaultKey (company-vault double-scope contract)", () => {
6561
+ it.each([
6562
+ // Company vault: companies/* keys are corrupt → forbidden.
6563
+ ["companies/frogbear/drafts/reports/signals-2026-06-15.html", false, true],
6564
+ ["companies/acme/knowledge/readme.md", false, true],
6565
+ ["companies/manifest.yaml", false, true],
6566
+ // Company vault: bucket-relative keys are legitimate → allowed.
6567
+ ["docs/handoff.md", false, false],
6568
+ ["knowledge/readme.md", false, false],
6569
+ // Personal vault: companies/* keys are NOT forbidden here (handled by the
6570
+ // personalMode branch, which carries its own nuanced gating).
6571
+ ["companies/frogbear/drafts/reports/signals-2026-06-15.html", true, false],
6572
+ ["docs/handoff.md", true, false],
6573
+ ])("key=%s personalMode=%s → forbidden=%s", (key, personalMode, expected) => {
6574
+ expect(isForbiddenCompanyVaultKey(key, personalMode)).toBe(expected);
6575
+ });
6576
+ });