@indigoai-us/hq-cloud 6.2.5 → 6.2.7

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.
@@ -210,16 +210,35 @@ export function resolveSkipPersonal(flag: boolean): boolean {
210
210
  return env === "1" || env === "true" || env === "yes";
211
211
  }
212
212
 
213
+ /**
214
+ * Email domains enrolled in the presigned-URL transport rollout.
215
+ *
216
+ * Staged by the operator (2026-06-10) in descending user count: getindigo.ai
217
+ * was the pilot; gmail.com / vyg.ai / amass.com are batch 2. The presign
218
+ * transport also routes every upload through the vault API's
219
+ * `validateObjectKey` (INVALID_KEY_BACKSLASH et al.), so enrolling a domain
220
+ * upgrades its server-side input validation versus the STS-direct path.
221
+ * Matching is on the EXACT domain (the part after the last `@`), not a
222
+ * suffix — `evil-gmail.com` and `gmail.com.evil.com` never match.
223
+ */
224
+ const PRESIGN_ROLLOUT_DOMAINS: ReadonlySet<string> = new Set([
225
+ "getindigo.ai",
226
+ "gmail.com",
227
+ "vyg.ai",
228
+ "amass.com",
229
+ ]);
230
+
213
231
  /**
214
232
  * Decide whether this session uses the presigned-URL transport.
215
233
  *
216
- * Rollout gate: ON for accounts whose verified email is `@getindigo.ai`.
217
- * `HQ_SYNC_PRESIGN_TRANSPORT` overrides the email check in both directions
218
- * (`1`/`true`/`yes`/`on` → force on, `0`/`false`/`no`/`off` → force off) so
219
- * the transport can be exercised by non-getindigo testers or rolled back for
220
- * getindigo accounts without a redeploy. An unset/blank override falls through
221
- * to the email check; an unrecognized override value is ignored (email check
222
- * wins) rather than silently forcing a state.
234
+ * Rollout gate: ON for accounts whose verified email domain is enrolled in
235
+ * `PRESIGN_ROLLOUT_DOMAINS`. `HQ_SYNC_PRESIGN_TRANSPORT` overrides the email
236
+ * check in both directions (`1`/`true`/`yes`/`on` → force on,
237
+ * `0`/`false`/`no`/`off` → force off) so the transport can be exercised by
238
+ * unenrolled testers or rolled back for enrolled accounts without a redeploy.
239
+ * An unset/blank override falls through to the email check; an unrecognized
240
+ * override value is ignored (email check wins) rather than silently forcing
241
+ * a state.
223
242
  */
224
243
  export function resolvePresignTransport(
225
244
  email: string | undefined,
@@ -228,7 +247,10 @@ export function resolvePresignTransport(
228
247
  const o = (override ?? "").trim().toLowerCase();
229
248
  if (o === "1" || o === "true" || o === "yes" || o === "on") return true;
230
249
  if (o === "0" || o === "false" || o === "no" || o === "off") return false;
231
- return typeof email === "string" && email.toLowerCase().endsWith("@getindigo.ai");
250
+ if (typeof email !== "string") return false;
251
+ const at = email.lastIndexOf("@");
252
+ if (at < 0) return false;
253
+ return PRESIGN_ROLLOUT_DOMAINS.has(email.slice(at + 1).toLowerCase());
232
254
  }
233
255
 
234
256
  // Personal-vault scope (exclusion list + path computer) lives in
@@ -274,6 +296,7 @@ export type RunnerEvent =
274
296
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
275
297
  | ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
276
298
  | { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
299
+ | { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
277
300
  | ({
278
301
  type: "complete";
279
302
  company: string;
@@ -1243,6 +1266,16 @@ export async function runRunner(
1243
1266
  company: companyLabel,
1244
1267
  files: event.files,
1245
1268
  });
1269
+ } else if (event.type === "scope-excluded") {
1270
+ // Push-side ACL scope exclusions — surface the named paths tagged to
1271
+ // this company so the menubar/CLI can show "N skipped, outside your
1272
+ // access" instead of the file silently never uploading.
1273
+ emit({
1274
+ type: "scope-excluded",
1275
+ company: companyLabel,
1276
+ count: event.count,
1277
+ samplePaths: event.samplePaths,
1278
+ });
1246
1279
  }
1247
1280
  };
1248
1281
 
@@ -1256,6 +1289,7 @@ export async function runRunner(
1256
1289
  filesRefusedStale: 0,
1257
1290
  filesRefusedStalePaths: [],
1258
1291
  filesExcludedByPolicy: 0,
1292
+ filesExcludedByScope: 0,
1259
1293
  conflictPaths: [],
1260
1294
  aborted: false,
1261
1295
  };
@@ -1307,6 +1341,26 @@ export async function runRunner(
1307
1341
  .map((p) => p.slug),
1308
1342
  );
1309
1343
 
1344
+ // Resolve the membership's effective ACL scope ONCE so BOTH the push and
1345
+ // pull legs respect the granted prefixes. The vault vends a child
1346
+ // credential scoped to these prefixes; without filtering the PUSH plan to
1347
+ // them, share() would HEAD/PUT keys outside the grant and the server's
1348
+ // correct 403 (SCOPE_EXCEEDS_PARENT) would abort the WHOLE company with a
1349
+ // generic error + exit 2. Personal-vault legs have no membership
1350
+ // sync-config — they stay full-scope ("all"). Degrades to "all" on any
1351
+ // error (a transient failure must never silently filter/prune the tree).
1352
+ // Hoisted above the push block (it used to be resolved only for pull) so
1353
+ // push gets the same scope; the pull leg below reuses this value.
1354
+ const scope: PullScope =
1355
+ target.personalMode === true
1356
+ ? { syncMode: "all" }
1357
+ : await resolvePullScope(
1358
+ client,
1359
+ target.uid,
1360
+ target.slug,
1361
+ parsed.hqRoot,
1362
+ );
1363
+
1310
1364
  if (doPush) {
1311
1365
  activePhase = "push";
1312
1366
  // For the personal slot we hand share() both (a) the top-level
@@ -1365,6 +1419,13 @@ export async function runRunner(
1365
1419
  ...(decommissionPrefixes && decommissionPrefixes.length > 0
1366
1420
  ? { decommissionPrefixes }
1367
1421
  : {}),
1422
+ // US-005 symmetry: scope the PUSH plan to the membership's granted
1423
+ // ACL prefixes so out-of-scope keys are skipped (and surfaced via a
1424
+ // `scope-excluded` event) instead of drawing the server's 403 and
1425
+ // aborting the company. `undefined` for `syncMode: "all"` (owner /
1426
+ // personal) → no scope filter, identical to the pre-fix shape so the
1427
+ // "company-target args" contract test stays green.
1428
+ ...(scope.prefixSet !== undefined ? { prefixSet: scope.prefixSet } : {}),
1368
1429
  });
1369
1430
  }
1370
1431
 
@@ -1373,20 +1434,11 @@ export async function runRunner(
1373
1434
  // whichever side `--on-conflict abort` just protected.
1374
1435
  if (doPull && !pushResult.aborted) {
1375
1436
  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
- );
1437
+ // US-005: the pull only materializes in-scope keys (and prunes clean
1438
+ // orphans when scope shrank). Reuse the `scope` resolved once above so
1439
+ // push and pull apply the SAME granted prefixes and we avoid a second
1440
+ // `listMyExplicitGrants` round-trip per company.
1441
+ const pullScope: PullScope = scope;
1390
1442
  pullResult = await syncFn({
1391
1443
  company: target.uid,
1392
1444
  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
  }