@indigoai-us/hq-cloud 6.2.5 → 6.2.6

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.
@@ -274,6 +274,7 @@ export type RunnerEvent =
274
274
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
275
275
  | ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
276
276
  | { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
277
+ | { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
277
278
  | ({
278
279
  type: "complete";
279
280
  company: string;
@@ -1243,6 +1244,16 @@ export async function runRunner(
1243
1244
  company: companyLabel,
1244
1245
  files: event.files,
1245
1246
  });
1247
+ } else if (event.type === "scope-excluded") {
1248
+ // Push-side ACL scope exclusions — surface the named paths tagged to
1249
+ // this company so the menubar/CLI can show "N skipped, outside your
1250
+ // access" instead of the file silently never uploading.
1251
+ emit({
1252
+ type: "scope-excluded",
1253
+ company: companyLabel,
1254
+ count: event.count,
1255
+ samplePaths: event.samplePaths,
1256
+ });
1246
1257
  }
1247
1258
  };
1248
1259
 
@@ -1256,6 +1267,7 @@ export async function runRunner(
1256
1267
  filesRefusedStale: 0,
1257
1268
  filesRefusedStalePaths: [],
1258
1269
  filesExcludedByPolicy: 0,
1270
+ filesExcludedByScope: 0,
1259
1271
  conflictPaths: [],
1260
1272
  aborted: false,
1261
1273
  };
@@ -1307,6 +1319,26 @@ export async function runRunner(
1307
1319
  .map((p) => p.slug),
1308
1320
  );
1309
1321
 
1322
+ // Resolve the membership's effective ACL scope ONCE so BOTH the push and
1323
+ // pull legs respect the granted prefixes. The vault vends a child
1324
+ // credential scoped to these prefixes; without filtering the PUSH plan to
1325
+ // them, share() would HEAD/PUT keys outside the grant and the server's
1326
+ // correct 403 (SCOPE_EXCEEDS_PARENT) would abort the WHOLE company with a
1327
+ // generic error + exit 2. Personal-vault legs have no membership
1328
+ // sync-config — they stay full-scope ("all"). Degrades to "all" on any
1329
+ // error (a transient failure must never silently filter/prune the tree).
1330
+ // Hoisted above the push block (it used to be resolved only for pull) so
1331
+ // push gets the same scope; the pull leg below reuses this value.
1332
+ const scope: PullScope =
1333
+ target.personalMode === true
1334
+ ? { syncMode: "all" }
1335
+ : await resolvePullScope(
1336
+ client,
1337
+ target.uid,
1338
+ target.slug,
1339
+ parsed.hqRoot,
1340
+ );
1341
+
1310
1342
  if (doPush) {
1311
1343
  activePhase = "push";
1312
1344
  // For the personal slot we hand share() both (a) the top-level
@@ -1365,6 +1397,13 @@ export async function runRunner(
1365
1397
  ...(decommissionPrefixes && decommissionPrefixes.length > 0
1366
1398
  ? { decommissionPrefixes }
1367
1399
  : {}),
1400
+ // US-005 symmetry: scope the PUSH plan to the membership's granted
1401
+ // ACL prefixes so out-of-scope keys are skipped (and surfaced via a
1402
+ // `scope-excluded` event) instead of drawing the server's 403 and
1403
+ // aborting the company. `undefined` for `syncMode: "all"` (owner /
1404
+ // personal) → no scope filter, identical to the pre-fix shape so the
1405
+ // "company-target args" contract test stays green.
1406
+ ...(scope.prefixSet !== undefined ? { prefixSet: scope.prefixSet } : {}),
1368
1407
  });
1369
1408
  }
1370
1409
 
@@ -1373,20 +1412,11 @@ export async function runRunner(
1373
1412
  // whichever side `--on-conflict abort` just protected.
1374
1413
  if (doPull && !pushResult.aborted) {
1375
1414
  activePhase = "pull";
1376
- // US-005: resolve the membership's effective download scope so the
1377
- // pull only materializes in-scope keys (and prunes clean orphans when
1378
- // scope shrank). Personal-vault legs have no membership sync-config
1379
- // they stay full-scope (`all`). Degrades to `all` on any error so a
1380
- // transient failure can't silently prune the tree.
1381
- const pullScope: PullScope =
1382
- target.personalMode === true
1383
- ? { syncMode: "all" }
1384
- : await resolvePullScope(
1385
- client,
1386
- target.uid,
1387
- target.slug,
1388
- parsed.hqRoot,
1389
- );
1415
+ // US-005: the pull only materializes in-scope keys (and prunes clean
1416
+ // orphans when scope shrank). Reuse the `scope` resolved once above so
1417
+ // push and pull apply the SAME granted prefixes and we avoid a second
1418
+ // `listMyExplicitGrants` round-trip per company.
1419
+ const pullScope: PullScope = scope;
1390
1420
  pullResult = await syncFn({
1391
1421
  company: target.uid,
1392
1422
  vaultConfig,
@@ -586,6 +586,114 @@ describe("share", () => {
586
586
  expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
587
587
  });
588
588
 
589
+ it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
590
+ // Real case (look-optic): a FILE_ACL grant covered {knowledge,policies,
591
+ // workers}/* + company.yaml, but the upload plan also contained
592
+ // settings/.gitkeep + projects/.gitkeep. Pre-fix, the push walked the
593
+ // whole company tree, HEAD'd an out-of-scope key, the scoped child
594
+ // credential drew a 403 SCOPE_EXCEEDS_PARENT, and the runner aborted the
595
+ // ENTIRE company (exit 2) naming no path. The fix scopes the push plan to
596
+ // the granted prefixSet (symmetric with the pull-side skip-out-of-scope):
597
+ // in-scope files upload, out-of-scope paths are skipped + surfaced via a
598
+ // `scope-excluded` event, and the company completes.
599
+ const companyRoot = path.join(tmpDir, "companies", "acme");
600
+ fs.mkdirSync(path.join(companyRoot, "knowledge"), { recursive: true });
601
+ fs.mkdirSync(path.join(companyRoot, "policies"), { recursive: true });
602
+ fs.mkdirSync(path.join(companyRoot, "settings"), { recursive: true });
603
+ fs.mkdirSync(path.join(companyRoot, "projects"), { recursive: true });
604
+ fs.writeFileSync(path.join(companyRoot, "company.yaml"), "name: Acme\n");
605
+ fs.writeFileSync(path.join(companyRoot, "knowledge", "readme.md"), "# kb\n");
606
+ fs.writeFileSync(path.join(companyRoot, "policies", "p.md"), "policy\n");
607
+ fs.writeFileSync(path.join(companyRoot, "settings", ".gitkeep"), "");
608
+ fs.writeFileSync(path.join(companyRoot, "projects", ".gitkeep"), "");
609
+
610
+ // No remote anywhere → every in-scope file is a clean upload.
611
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
612
+
613
+ const events: Array<{ type?: string; path?: string; count?: number; samplePaths?: string[] }> = [];
614
+ const result = await share({
615
+ paths: [companyRoot],
616
+ company: "acme",
617
+ vaultConfig: mockConfig,
618
+ hqRoot: tmpDir,
619
+ // Coalesced company-relative grant prefixes (what resolvePullScope hands
620
+ // the runner for a shared membership).
621
+ prefixSet: ["company.yaml", "knowledge/", "policies/", "workers/"],
622
+ onEvent: (e) => events.push(e as { type?: string }),
623
+ });
624
+
625
+ // In-scope subset uploaded; out-of-scope never PUT. (company.yaml is in
626
+ // the grant but is independently dropped by the base ignore filter — it's
627
+ // in DEFAULT_IGNORES — so it never uploads regardless of scope; the scope
628
+ // filter layers on top of the ignore filter, not under it.)
629
+ const uploadedPaths = events
630
+ .filter((e) => e.type === "progress")
631
+ .map((e) => e.path)
632
+ .sort();
633
+ expect(uploadedPaths).toEqual(["knowledge/readme.md", "policies/p.md"]);
634
+ expect(result.filesUploaded).toBe(2);
635
+ // The two out-of-scope directories were excluded and named.
636
+ expect(result.filesExcludedByScope).toBe(2);
637
+ const scopeEv = events.find((e) => e.type === "scope-excluded") as
638
+ | { count: number; samplePaths: string[] }
639
+ | undefined;
640
+ expect(scopeEv).toBeDefined();
641
+ expect(scopeEv!.count).toBe(2);
642
+ expect(scopeEv!.samplePaths.sort()).toEqual(["projects/", "settings/"]);
643
+ // No conflict, no error — the company completed cleanly (no abort).
644
+ expect(result.aborted).toBe(false);
645
+ expect(events.some((e) => e.type === "error")).toBe(false);
646
+ // uploadFile was never called for an out-of-scope key.
647
+ const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
648
+ expect(putKeys).not.toContain("settings/.gitkeep");
649
+ expect(putKeys).not.toContain("projects/.gitkeep");
650
+ });
651
+
652
+ it("scoped push defense-in-depth: a 403 on HEAD skips that key (named error) instead of aborting the company", async () => {
653
+ // Belt-and-suspenders: even if a key slips past the prefix filter (a grant
654
+ // that changed mid-run, a pin outside the grant, prefix-coalesce
655
+ // imprecision), the server's correct 403 on the HEAD must NOT abort the
656
+ // whole company. Pre-fix the HEAD sat outside the per-file PUT try/catch,
657
+ // so the throw bubbled to workerErrors -> `throw first` -> exit 2.
658
+ const companyRoot = path.join(tmpDir, "companies", "acme");
659
+ fs.mkdirSync(companyRoot, { recursive: true });
660
+ const okFile = path.join(companyRoot, "in-scope.md");
661
+ const blockedFile = path.join(companyRoot, "out-of-reach.md");
662
+ fs.writeFileSync(okFile, "ok\n");
663
+ fs.writeFileSync(blockedFile, "denied\n");
664
+
665
+ // The scoped credential 403s on the out-of-scope key's HEAD; the other
666
+ // key heads cleanly (no remote).
667
+ vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => {
668
+ if (key === "out-of-reach.md") {
669
+ const err = new Error("access denied");
670
+ (err as { name: string }).name = "AccessDenied";
671
+ throw err;
672
+ }
673
+ return null;
674
+ });
675
+
676
+ const events: Array<{ type?: string; path?: string; message?: string }> = [];
677
+ const result = await share({
678
+ paths: [okFile, blockedFile],
679
+ company: "acme",
680
+ vaultConfig: mockConfig,
681
+ hqRoot: tmpDir,
682
+ onEvent: (e) => events.push(e as { type?: string }),
683
+ });
684
+
685
+ // Company did NOT abort; the in-scope file still uploaded.
686
+ expect(result.aborted).toBe(false);
687
+ expect(result.filesUploaded).toBe(1);
688
+ const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
689
+ expect(putKeys).toEqual(["in-scope.md"]);
690
+ // The blocked key surfaced as a path-named, scope-clear error event.
691
+ const errs = events.filter((e) => e.type === "error") as Array<{ path?: string; message?: string }>;
692
+ expect(errs).toHaveLength(1);
693
+ expect(errs[0].path).toBe("out-of-reach.md");
694
+ expect(errs[0].message).toMatch(/outside granted ACL scope/i);
695
+ });
696
+
589
697
  it("uploads (no conflict) when only the local side changed since last sync", async () => {
590
698
  // Regression for hq-cloud#<conflict-detection>: a local edit to a file
591
699
  // that exists on S3 used to trigger a push conflict because the
@@ -810,6 +918,7 @@ describe("share", () => {
810
918
  e.type === "plan" ||
811
919
  e.type === "new-files" ||
812
920
  e.type === "personal-vault-out-of-policy" ||
921
+ e.type === "scope-excluded" ||
813
922
  e.type === "delete-refused-bulk-asymmetry"
814
923
  ) return;
815
924
  events.push({
package/src/cli/share.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  import { resolveConflict } from "./conflict.js";
42
42
  import type { ConflictStrategy } from "./conflict.js";
43
43
  import type { SyncProgressEvent } from "./sync.js";
44
+ import { isCoveredByAny, isDirInScope } from "../prefix-coalesce.js";
44
45
  import {
45
46
  buildConflictId,
46
47
  buildConflictPath,
@@ -591,6 +592,20 @@ export interface ShareOptions {
591
592
  * `sync-journal.personal.json` file.
592
593
  */
593
594
  journalSlug?: string;
595
+ /**
596
+ * Effective ACL push scope as a list of company-relative prefixes (the same
597
+ * coalesced `prefixSet` the pull leg uses for `syncMode: "shared"`). When
598
+ * provided, the upload + delete plans are filtered to paths covered by these
599
+ * prefixes — any candidate outside them is skipped (and surfaced via a
600
+ * `scope-excluded` event) rather than PUT, because the vended child
601
+ * credential is scoped to exactly these prefixes and an out-of-scope PUT
602
+ * draws the server's correct 403 `SCOPE_EXCEEDS_PARENT`.
603
+ *
604
+ * `undefined` (the owner/`all` case, and `hq share <file>`) applies NO scope
605
+ * filter — full access. An empty array means "no granted prefixes" → every
606
+ * path is out of scope (mirrors the pull side's `isCoveredByAny([])`).
607
+ */
608
+ prefixSet?: string[];
594
609
  }
595
610
 
596
611
  export interface ShareResult {
@@ -644,6 +659,16 @@ export interface ShareResult {
644
659
  * emitted exactly once if this is > 0).
645
660
  */
646
661
  filesExcludedByPolicy: number;
662
+ /**
663
+ * Number of distinct company-relative paths/prefixes skipped because they
664
+ * fell OUTSIDE the run's ACL `prefixSet` (member/guest scoped push). Always
665
+ * 0 when `prefixSet` is undefined (owner/`all`) or the whole tree is in
666
+ * scope. Mirrors the `count` field of the `scope-excluded` event (emitted
667
+ * once if this is > 0). These paths were never PUT, so the server's correct
668
+ * 403 `SCOPE_EXCEEDS_PARENT` is never triggered and the company still syncs
669
+ * its in-scope subset.
670
+ */
671
+ filesExcludedByScope: number;
647
672
  /**
648
673
  * Paths (company-relative) that were detected as push conflicts. Mirrors
649
674
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -653,6 +678,56 @@ export interface ShareResult {
653
678
  aborted: boolean;
654
679
  }
655
680
 
681
+ /**
682
+ * Is this error the S3/STS "access denied" class? Expected when a scoped
683
+ * member/guest credential touches a key outside its granted ACL prefixes
684
+ * (the server's `SCOPE_EXCEEDS_PARENT` surfaces as a 403 AccessDenied /
685
+ * Forbidden). Mirrors the pull-side `isAccessDenied` in sync.ts so the push
686
+ * leg can treat a stray out-of-scope key as a skip rather than a fatal throw.
687
+ */
688
+ function isAccessDenied(err: unknown): boolean {
689
+ if (err && typeof err === "object" && "name" in err) {
690
+ const name = (err as { name?: unknown }).name;
691
+ return name === "AccessDenied" || name === "Forbidden";
692
+ }
693
+ return false;
694
+ }
695
+
696
+ /**
697
+ * Wrap an existing path filter (ignore filter, optionally already wrapped with
698
+ * the personal-vault defaults) so that paths OUTSIDE the run's ACL `prefixSet`
699
+ * are rejected before they enter the upload OR delete plan. Keeps the
700
+ * `(absolutePath, isDir) => boolean` shape the rest of share() already uses
701
+ * (collectFiles / walkDir / computeDeletePlan), so wiring is one line at the
702
+ * filter-construction site — exactly like `wrapFilterWithPersonalVaultDefaults`.
703
+ *
704
+ * Files use `isCoveredByAny` (plain `startsWith`); directories use
705
+ * `isDirInScope` (descend if the dir is inside a grant OR a grant is inside
706
+ * the dir) so the walk still reaches deep/exact-file grants. Rejected entries
707
+ * are tagged via `onScopeExcluded` (directories with a trailing slash) so the
708
+ * runner can emit a single `scope-excluded` summary naming what was skipped.
709
+ */
710
+ function wrapFilterWithScope(
711
+ underlying: (absPath: string, isDir?: boolean) => boolean,
712
+ syncRoot: string,
713
+ prefixSet: readonly string[],
714
+ onScopeExcluded: (rel: string) => void,
715
+ ): (absPath: string, isDir?: boolean) => boolean {
716
+ return (absPath: string, isDir?: boolean) => {
717
+ if (!underlying(absPath, isDir)) return false;
718
+ const rel = path.relative(syncRoot, absPath).split(path.sep).join("/");
719
+ if (rel === "" || rel.startsWith("..")) return true; // root / outside — defer
720
+ if (isDir) {
721
+ if (isDirInScope(rel, prefixSet)) return true;
722
+ onScopeExcluded(rel.endsWith("/") ? rel : rel + "/");
723
+ return false;
724
+ }
725
+ if (isCoveredByAny(rel, prefixSet)) return true;
726
+ onScopeExcluded(rel);
727
+ return false;
728
+ };
729
+ }
730
+
656
731
  /**
657
732
  * Share local file(s) to the entity vault.
658
733
  */
@@ -768,9 +843,22 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
768
843
  excludedSet.add(rel);
769
844
  excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
770
845
  };
771
- const shouldSync = options.personalMode === true
846
+ // ACL scope filter (member/guest scoped push). The vended child credential
847
+ // is scoped to `options.prefixSet`; any candidate outside those prefixes
848
+ // would draw the server's correct 403 SCOPE_EXCEEDS_PARENT on PUT and abort
849
+ // the whole company. Pre-filter the plan to the granted subset instead —
850
+ // the push-side analogue of the pull leg's `skip-out-of-scope` (US-005).
851
+ // `undefined` = owner/`all` → no scope filter (full access).
852
+ const scopeExcludedSet = new Set<string>();
853
+ const onScopeExcluded = (rel: string) => {
854
+ scopeExcludedSet.add(rel);
855
+ };
856
+ const baseFilter = options.personalMode === true
772
857
  ? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
773
858
  : ignoreFilter;
859
+ const shouldSync = options.prefixSet !== undefined
860
+ ? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
861
+ : baseFilter;
774
862
  const journalSlug = options.journalSlug ?? ctx.slug;
775
863
  // Seed the canonical personal-vault journal from the legacy `personal` file
776
864
  // exactly once — engine-side so every consumer (sync-runner, hq-cli) gets
@@ -1008,7 +1096,32 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1008
1096
  // single-part etag) and compare. Match → no conflict, silently skip
1009
1097
  // the PUT (the bytes are already there). Mismatch → treat as a
1010
1098
  // conflict in the same shared branch below.
1011
- const remoteMeta = await headRemoteFile(ctx, relativePath);
1099
+ // Defense-in-depth for the scoped-push 403: the `prefixSet` filter above
1100
+ // should already have dropped any out-of-scope key from the plan, but a
1101
+ // grant that changed mid-run, a pinned prefix outside the grant, or
1102
+ // prefix-coalesce imprecision can still leave an out-of-scope key here.
1103
+ // This HEAD sits OUTSIDE the per-file PUT try/catch below, so a thrown
1104
+ // 403 used to bubble to `workerErrors` and abort the ENTIRE company with
1105
+ // a generic message and exit 2. Catch the access-denied class, surface
1106
+ // the offending PATH clearly, and skip just this key — the rest of the
1107
+ // company still syncs. Non-access-denied errors re-throw unchanged.
1108
+ let remoteMeta: Awaited<ReturnType<typeof headRemoteFile>>;
1109
+ try {
1110
+ remoteMeta = await headRemoteFile(ctx, relativePath);
1111
+ } catch (headErr) {
1112
+ if (isAccessDenied(headErr)) {
1113
+ emit({
1114
+ type: "error",
1115
+ path: relativePath,
1116
+ message:
1117
+ "skipped: outside granted ACL scope (server returned 403 " +
1118
+ "SCOPE_EXCEEDS_PARENT / access denied on HEAD). Grant this path " +
1119
+ "to push it, or it stays local-only.",
1120
+ });
1121
+ return;
1122
+ }
1123
+ throw headErr;
1124
+ }
1012
1125
  if (remoteMeta) {
1013
1126
  const journalEntry = journal.files[relativePath];
1014
1127
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
@@ -1214,7 +1327,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1214
1327
  emit({
1215
1328
  type: "error",
1216
1329
  path: relativePath,
1217
- message: err instanceof Error ? err.message : String(err),
1330
+ message: isAccessDenied(err)
1331
+ ? "skipped: outside granted ACL scope (server returned 403 " +
1332
+ "SCOPE_EXCEEDS_PARENT / access denied on PUT). Grant this path " +
1333
+ "to push it, or it stays local-only."
1334
+ : err instanceof Error
1335
+ ? err.message
1336
+ : String(err),
1218
1337
  });
1219
1338
  }
1220
1339
  };
@@ -1284,6 +1403,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1284
1403
  // abort (matches the existing convention: abort short-circuits
1285
1404
  // before the end-of-run telemetry emits).
1286
1405
  filesExcludedByPolicy: excludedSet.size,
1406
+ // Scope exclusions are likewise computed during the upload walk, so the
1407
+ // count is meaningful on the abort path too.
1408
+ filesExcludedByScope: scopeExcludedSet.size,
1287
1409
  // Use the snapshot of conflictPaths taken at the moment the abort
1288
1410
  // fired — additional in-flight items may have appended to the
1289
1411
  // shared array after the abort signal, and those should not show
@@ -1403,6 +1525,24 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1403
1525
  });
1404
1526
  }
1405
1527
 
1528
+ // ACL scope-exclusion summary. Emit at most once when one or more candidate
1529
+ // paths fell outside the run's granted `prefixSet` and were skipped from the
1530
+ // upload/delete plan. Informational (NOT an error): the company still syncs
1531
+ // its in-scope subset and the run exits 0. Sample capped at 10 (insertion
1532
+ // order = walk order, deterministic).
1533
+ if (scopeExcludedSet.size > 0) {
1534
+ const samplePaths: string[] = [];
1535
+ for (const p of scopeExcludedSet) {
1536
+ samplePaths.push(p);
1537
+ if (samplePaths.length >= 10) break;
1538
+ }
1539
+ emit({
1540
+ type: "scope-excluded",
1541
+ count: scopeExcludedSet.size,
1542
+ samplePaths,
1543
+ });
1544
+ }
1545
+
1406
1546
  // Codex P1 (5.36.x): if any worker rejected (unhandled error in
1407
1547
  // headRemoteFile / refreshEntityContext / resolveConflict — paths
1408
1548
  // outside the per-item PUT try/catch), we deliberately let the pool
@@ -1429,6 +1569,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1429
1569
  filesRefusedStale,
1430
1570
  filesRefusedStalePaths,
1431
1571
  filesExcludedByPolicy: excludedSet.size,
1572
+ filesExcludedByScope: scopeExcludedSet.size,
1432
1573
  conflictPaths,
1433
1574
  aborted: false,
1434
1575
  };
package/src/cli/sync.ts CHANGED
@@ -241,6 +241,28 @@ export type SyncProgressEvent =
241
241
  count: number;
242
242
  samplePaths: string[];
243
243
  byId: Record<string, number>;
244
+ }
245
+ | {
246
+ /**
247
+ * Emitted at most ONCE per `share()` call (push leg) when the run is
248
+ * scoped to a member/guest's ACL prefixes (`prefixSet`) and one or more
249
+ * candidate paths fell OUTSIDE the granted prefixes. Those paths are
250
+ * skipped from the upload (and delete) plan instead of being PUT — the
251
+ * vended child credential is scoped to the granted prefixes, so pushing
252
+ * them would draw the server's correct 403 `SCOPE_EXCEEDS_PARENT` and
253
+ * (pre-fix) abort the WHOLE company. This is the push-side analogue of
254
+ * the pull-side `skip-out-of-scope` action.
255
+ *
256
+ * `count` is the number of distinct company-relative paths/prefixes the
257
+ * scope filter excluded; `samplePaths` carries up to 10 for diagnostic
258
+ * display. Informational, NOT an error — the company still syncs its
259
+ * in-scope subset and the run exits 0.
260
+ *
261
+ * Not emitted when `count === 0` — silent on a fully in-scope tree.
262
+ */
263
+ type: "scope-excluded";
264
+ count: number;
265
+ samplePaths: string[];
244
266
  };
245
267
 
246
268
  export interface SyncOptions {
@@ -1877,5 +1899,15 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
1877
1899
  console.log(` ... and ${event.files.length - MAX_SHOWN} more`);
1878
1900
  }
1879
1901
  }
1902
+ } else if (event.type === "scope-excluded") {
1903
+ console.log(
1904
+ ` ~ ${event.count} path${event.count === 1 ? "" : "s"} skipped — outside your granted access (synced the in-scope subset):`,
1905
+ );
1906
+ for (const p of event.samplePaths) {
1907
+ console.log(` · ${p}`);
1908
+ }
1909
+ if (event.count > event.samplePaths.length) {
1910
+ console.log(` ... and ${event.count - event.samplePaths.length} more`);
1911
+ }
1880
1912
  }
1881
1913
  }