@hraness/ghostget 0.17.5 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +16 -9
  3. package/dist/apple-photos-client.js +1 -1
  4. package/dist/beeper-client.js +1 -1
  5. package/dist/{index-pf74yjs2.js → index-9wca02er.js} +1 -1
  6. package/docs/control-panel.md +147 -0
  7. package/package.json +47 -7
  8. package/skills/ghostget/SKILL.md +3 -1
  9. package/skills/ghostget/references/control-panel.md +43 -0
  10. package/skills/ghostget/references/install.md +5 -5
  11. package/skills/ghostget/references/linkedin-adapter.md +17 -3
  12. package/skills/ghostget/references/platform-patterns.md +1 -1
  13. package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
  14. package/src/auth.ts +35 -1
  15. package/src/beeper-client-types.ts +1 -1
  16. package/src/cli.ts +18 -0
  17. package/src/confirmed-write-platform.ts +16 -1
  18. package/src/control/account-revision.ts +16 -0
  19. package/src/control/activity.ts +104 -0
  20. package/src/control/approval-broker.ts +59 -0
  21. package/src/control/approval-client.ts +49 -0
  22. package/src/control/bundled-interfaces.ts +20 -0
  23. package/src/control/cli.ts +20 -0
  24. package/src/control/connections.ts +87 -0
  25. package/src/control/credential-helper.ts +152 -0
  26. package/src/control/helper.ts +79 -0
  27. package/src/control/interface-cli.ts +22 -0
  28. package/src/control/interface-json.ts +94 -0
  29. package/src/control/interface-schema.ts +120 -0
  30. package/src/control/interfaces.ts +438 -0
  31. package/src/control/protocol.ts +182 -0
  32. package/src/control/service.ts +104 -0
  33. package/src/control/validation.ts +103 -0
  34. package/src/control/vault.ts +105 -0
  35. package/src/control/web-gateway.ts +62 -0
  36. package/src/control/web-policy.ts +56 -0
  37. package/src/ghostget.ts +2 -0
  38. package/src/messaging-runtime.ts +3 -0
  39. package/src/oauth-google.ts +11 -5
  40. package/src/omni-runtime.ts +18 -3
  41. package/src/operation-permission-store.ts +92 -0
  42. package/src/operation-permission.ts +308 -0
  43. package/src/pinned-https.ts +5 -0
  44. package/src/provider-http.ts +11 -3
  45. package/src/provider-plugin-contract-identity.ts +2 -2
  46. package/src/provider-plugin-import-analysis.ts +52 -0
  47. package/src/provider-plugin-module-analysis.ts +21 -1
  48. package/src/provider-plugin-registry.ts +4 -8
  49. package/src/provider-plugin.ts +4 -8
  50. package/src/providers/linkedin-web-contact.ts +237 -21
  51. package/src/read-client.ts +12 -2
  52. package/src/runtime.ts +81 -9
  53. package/src/state-helper.ts +2 -0
  54. package/src/storage.ts +71 -1
  55. package/src/usage.ts +6 -0
  56. package/src/version.ts +1 -1
@@ -25,6 +25,7 @@ import {
25
25
  sep,
26
26
  } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
+ import { scanProviderPluginValueImports } from "./provider-plugin-import-analysis";
28
29
 
29
30
  import type { GhostgetAuth } from "./auth";
30
31
  import type { MessageLikeMeSourceConversationCoordinateBindingV1 } from "./message-like-me-agentic-messaging";
@@ -1339,10 +1340,6 @@ const MAX_PROVIDER_PLUGIN_EVALUATION_PACKAGE_DEPTH = 32;
1339
1340
  const MAX_PROVIDER_PLUGIN_EVALUATION_PACKAGE_PATH_BYTES = 1_024;
1340
1341
  const MAX_PROVIDER_PLUGIN_EVALUATION_PACKAGE_BYTES = 128 * 1024 * 1024;
1341
1342
  const MAX_PROVIDER_PLUGIN_EVALUATION_PACKAGE_CACHE_ENTRIES = 256;
1342
- const providerPluginEvaluationImportScanners = Object.freeze({
1343
- js: new Bun.Transpiler({ loader: "js" }),
1344
- ts: new Bun.Transpiler({ loader: "ts" }),
1345
- });
1346
1343
  const providerPluginEvaluationModuleExtensions = new Set([
1347
1344
  ".cjs",
1348
1345
  ".cts",
@@ -2194,11 +2191,10 @@ function providerPluginEvaluationValueImports(
2194
2191
  `provider plugin evaluation module ${path} must be valid UTF-8`,
2195
2192
  );
2196
2193
  }
2197
- const scanner =
2194
+ const loader =
2198
2195
  extension === ".ts" || extension === ".mts" || extension === ".cts"
2199
- ? providerPluginEvaluationImportScanners.ts
2200
- : providerPluginEvaluationImportScanners.js;
2201
- const imports = Object.freeze([...scanner.scanImports(source)]);
2196
+ ? "ts" : "js";
2197
+ const imports = scanProviderPluginValueImports(source, loader);
2202
2198
  if (imports.length > MAX_PROVIDER_PLUGIN_EVALUATION_IMPORTS_PER_MODULE) {
2203
2199
  throw new Error(
2204
2200
  `provider plugin evaluation module ${path} has too many static imports`,
@@ -42,7 +42,28 @@ const MAX_CODE_TAGS = 256;
42
42
  const MAX_COMO_ASSIGNMENTS = 8;
43
43
  const MAX_COMO_DECODED_ROOTS = 256;
44
44
  const MAX_WALK_NODES = 500_000;
45
+ const MAX_WALK_DEPTH = 128;
45
46
  const RSC_FLIGHT_ROW = /(?:^|\n)(\d+):/u;
47
+ const PROFILE_ID = /^[A-Za-z0-9_-]{1,256}$/u;
48
+ const DISTANCE_IN_TEXT =
49
+ /(?:^|["'\\{,])(?:networkDistance|memberDistance|distance)"?\s*:\s*"?(DISTANCE_[A-Z0-9]+|OUT_OF_NETWORK|SELF|[0-9]+)"?/gu;
50
+ const PROFILE_URN_IN_TEXT = /urn:li:fsd_profile:[A-Za-z0-9_-]{1,256}/gu;
51
+ const VIEWEE_PROFILE_ID_IN_TEXT = /vieweeProfileId"\s*:\s*"([A-Za-z0-9_-]{1,256})"/gu;
52
+ const VANITY_IN_TEXT =
53
+ /(?:vanityName|publicIdentifier)"\s*:\s*"([A-Za-z0-9][A-Za-z0-9_-]{1,99})"/gu;
54
+ const KEYED_IDENTITY_KEYS = new Set([
55
+ "distance",
56
+ "entityUrn",
57
+ "isSelfView",
58
+ "memberDistance",
59
+ "networkDistance",
60
+ "objectUrn",
61
+ "profileUrn",
62
+ "publicIdentifier",
63
+ "vanityName",
64
+ "vieweeMemberUrn",
65
+ "vieweeProfileId",
66
+ ]);
46
67
  const MAX_PHONES = 8;
47
68
  const MAX_WEBSITES = 8;
48
69
  const CONTACT_TYPE_SUFFIX = "ProfileContactInfo";
@@ -315,6 +336,99 @@ function looksLikeRscFlight(value: string): boolean {
315
336
  return RSC_FLIGHT_ROW.test(value);
316
337
  }
317
338
 
339
+ function extractJsonString(source: string, start: number): string | undefined {
340
+ if (source[start] !== "\"") return undefined;
341
+ let escaped = false;
342
+ const limit = Math.min(source.length, start + MAX_HTML_BYTES);
343
+ for (let index = start + 1; index < limit; index += 1) {
344
+ const character = source[index];
345
+ if (character === undefined) break;
346
+ if (escaped) {
347
+ escaped = false;
348
+ continue;
349
+ }
350
+ if (character === "\\") {
351
+ escaped = true;
352
+ continue;
353
+ }
354
+ if (character === "\"") return source.slice(start, index + 1);
355
+ }
356
+ return undefined;
357
+ }
358
+
359
+ function breadcrumbRecordFromText(value: string): JsonRecord | null {
360
+ if (value.length < 8 || value.length > 1024 * 1024) return null;
361
+ const distances = [...value.matchAll(DISTANCE_IN_TEXT)]
362
+ .map((match) => match[1])
363
+ .filter((item): item is string => item !== undefined);
364
+ const urns = [...value.matchAll(PROFILE_URN_IN_TEXT)];
365
+ const vieweeIds = [...value.matchAll(VIEWEE_PROFILE_ID_IN_TEXT)]
366
+ .map((match) => match[1])
367
+ .filter((item): item is string => item !== undefined);
368
+ const vanities = [...value.matchAll(VANITY_IN_TEXT)]
369
+ .map((match) => match[1])
370
+ .filter((item): item is string => item !== undefined);
371
+ if (
372
+ distances.length === 0
373
+ && urns.length === 0
374
+ && vieweeIds.length === 0
375
+ && vanities.length === 0
376
+ ) return null;
377
+ const record: Record<string, unknown> = {};
378
+ if (distances.length === 1) {
379
+ const raw = distances[0]!;
380
+ record.networkDistance = /^[0-9]+$/u.test(raw) ? Number(raw) : raw;
381
+ } else if (distances.length > 1) {
382
+ const unique = [...new Set(distances)];
383
+ if (unique.length === 1) {
384
+ const raw = unique[0]!;
385
+ record.networkDistance = /^[0-9]+$/u.test(raw) ? Number(raw) : raw;
386
+ }
387
+ }
388
+ if (urns.length === 1) record.profileUrn = urns[0]![0];
389
+ if (vieweeIds.length === 1) record.vieweeProfileId = vieweeIds[0];
390
+ if (vanities.length === 1) record.vanityName = vanities[0];
391
+ return Object.keys(record).length > 0 ? Object.freeze(record) : null;
392
+ }
393
+
394
+ function stringLooksLikeBootstrap(value: string): boolean {
395
+ return value.includes("networkDistance")
396
+ || value.includes("memberDistance")
397
+ || value.includes("vieweeProfileId")
398
+ || value.includes("vieweeMemberUrn")
399
+ || value.includes("vanityName")
400
+ || value.includes("publicIdentifier")
401
+ || /"distance"\s*:/u.test(value);
402
+ }
403
+
404
+ function decodeStringBootstrap(value: string): unknown[] {
405
+ const trimmed = value.trim();
406
+ if (trimmed.length < 8 || trimmed.length > 1024 * 1024) return [];
407
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
408
+ try {
409
+ return [JSON.parse(trimmed) as unknown];
410
+ } catch {
411
+ // Fall through to breadcrumb extraction from partial RSC string rows.
412
+ }
413
+ }
414
+ if (!stringLooksLikeBootstrap(trimmed)) return [];
415
+ const breadcrumb = breadcrumbRecordFromText(trimmed);
416
+ return breadcrumb === null ? [] : [breadcrumb];
417
+ }
418
+
419
+ function recordFromKeyedArray(value: readonly unknown[]): JsonRecord | null {
420
+ if (value.length < 2 || value.length % 2 !== 0 || value.length > 64) return null;
421
+ const record: Record<string, unknown> = {};
422
+ let recognized = 0;
423
+ for (let index = 0; index < value.length; index += 2) {
424
+ const key = value[index];
425
+ if (typeof key !== "string" || key.length < 1 || key.length > 128) return null;
426
+ record[key] = value[index + 1];
427
+ if (KEYED_IDENTITY_KEYS.has(key)) recognized += 1;
428
+ }
429
+ return recognized > 0 ? Object.freeze(record) : null;
430
+ }
431
+
318
432
  function decodeRscFlightRecords(text: string): unknown[] {
319
433
  const records: unknown[] = [];
320
434
  let index = 0;
@@ -334,7 +448,28 @@ function decodeRscFlightRecords(text: string): unknown[] {
334
448
  index += json.length;
335
449
  continue;
336
450
  }
337
- const nextRow = text.slice(index).search(/\n\d+:/u);
451
+ if (first === "\"") {
452
+ const json = extractJsonString(text, index);
453
+ if (json === undefined) {
454
+ index += 1;
455
+ continue;
456
+ }
457
+ try {
458
+ const decoded = JSON.parse(json) as unknown;
459
+ if (typeof decoded === "string") records.push(...decodeStringBootstrap(decoded));
460
+ else records.push(decoded);
461
+ } catch {
462
+ const breadcrumb = breadcrumbRecordFromText(json);
463
+ if (breadcrumb !== null) records.push(breadcrumb);
464
+ }
465
+ index += json.length;
466
+ continue;
467
+ }
468
+ const remainder = text.slice(index);
469
+ const nextRow = remainder.search(/\n\d+:/u);
470
+ const rowText = nextRow === -1 ? remainder : remainder.slice(0, nextRow);
471
+ const breadcrumb = breadcrumbRecordFromText(rowText);
472
+ if (breadcrumb !== null) records.push(breadcrumb);
338
473
  index = nextRow === -1 ? text.length : index + nextRow + 1;
339
474
  }
340
475
  return records;
@@ -433,13 +568,21 @@ function embeddedRecords(html: unknown): readonly JsonRecord[] {
433
568
  while (stack.length > 0) {
434
569
  const next = stack.pop()!;
435
570
  nodes += 1;
436
- if (nodes > MAX_WALK_NODES || next.depth > 32) {
571
+ if (nodes > MAX_WALK_NODES || next.depth > MAX_WALK_DEPTH) {
437
572
  throw new Error("LinkedIn contact-info bootstrap exceeded its traversal bound");
438
573
  }
574
+ if (typeof next.value === "string") {
575
+ for (const value of decodeStringBootstrap(next.value)) {
576
+ stack.push({ value, depth: next.depth + 1 });
577
+ }
578
+ continue;
579
+ }
439
580
  if (Array.isArray(next.value)) {
440
581
  if (next.value.length > 20_000) {
441
582
  throw new Error("LinkedIn contact-info bootstrap array exceeded its reviewed bound");
442
583
  }
584
+ const keyed = recordFromKeyedArray(next.value);
585
+ if (keyed !== null) records.push(keyed);
443
586
  for (const value of next.value) stack.push({ value, depth: next.depth + 1 });
444
587
  continue;
445
588
  }
@@ -505,17 +648,33 @@ function vanityFromRecord(record: JsonRecord): string | null {
505
648
  ?? vanityFromHref(record.navigationUrl);
506
649
  }
507
650
 
508
- function profileUrnFromRecord(record: JsonRecord): string | null {
509
- for (const key of ["entityUrn", "objectUrn", "profileUrn", "vieweeMemberUrn"] as const) {
510
- const value = record[key];
511
- if (typeof value !== "string") continue;
651
+ function profileUrnFromIdentity(value: unknown): string | null {
652
+ if (typeof value !== "string" || value.length < 1 || value.length > 512) return null;
653
+ if (value.startsWith("urn:li:fsd_profile:")) {
512
654
  try {
513
655
  return profileUrn(value);
514
656
  } catch {
515
- continue;
657
+ return null;
516
658
  }
517
659
  }
518
- return null;
660
+ if (!PROFILE_ID.test(value)) return null;
661
+ try {
662
+ return profileUrn(`urn:li:fsd_profile:${value}`);
663
+ } catch {
664
+ return null;
665
+ }
666
+ }
667
+
668
+ function profileUrnFromRecord(record: JsonRecord): string | null {
669
+ for (const key of ["entityUrn", "objectUrn", "profileUrn", "vieweeMemberUrn"] as const) {
670
+ const urn = profileUrnFromIdentity(record[key]);
671
+ if (urn !== null) return urn;
672
+ }
673
+ return profileUrnFromIdentity(record.vieweeProfileId);
674
+ }
675
+
676
+ function recordIsSelfView(record: JsonRecord): boolean {
677
+ return record.isSelfView === true;
519
678
  }
520
679
 
521
680
  function selfProfileError(): Error {
@@ -524,6 +683,68 @@ function selfProfileError(): Error {
524
683
  );
525
684
  }
526
685
 
686
+ function omittedDistanceError(): Error {
687
+ return new Error(
688
+ "LinkedIn contact-info profile page omitted or contradicted its relationship distance",
689
+ );
690
+ }
691
+
692
+ function uniqueDistances(values: readonly (string | number)[]): (string | number)[] {
693
+ const unique = [...new Set(values.map((value) => String(value)))];
694
+ return unique.map((value) => values.find((candidate) => String(candidate) === value)!);
695
+ }
696
+
697
+ function recordMatchesTarget(
698
+ record: JsonRecord,
699
+ slug: string,
700
+ urn: string,
701
+ ): boolean {
702
+ const vanity = vanityFromRecord(record);
703
+ const recordUrn = profileUrnFromRecord(record);
704
+ return vanity === slug || recordUrn === urn;
705
+ }
706
+
707
+ function recordContradictsTarget(
708
+ record: JsonRecord,
709
+ slug: string,
710
+ urn: string,
711
+ ): boolean {
712
+ const vanity = vanityFromRecord(record);
713
+ const recordUrn = profileUrnFromRecord(record);
714
+ return (vanity !== null && vanity !== slug) || (recordUrn !== null && recordUrn !== urn);
715
+ }
716
+
717
+ function relationshipDistance(
718
+ records: readonly JsonRecord[],
719
+ slug: string,
720
+ urn: string,
721
+ viewer: string,
722
+ ): string | number {
723
+ const bound = records
724
+ .filter((record) => recordMatchesTarget(record, slug, urn))
725
+ .map(distanceValue)
726
+ .filter((value): value is string | number => value !== null);
727
+ const boundOther = bound.filter((value) => !isSelfDistance(value));
728
+ const boundUnique = uniqueDistances(boundOther);
729
+ if (boundUnique.length === 1 && boundUnique[0] !== undefined) return boundUnique[0];
730
+ if (boundUnique.length > 1) throw omittedDistanceError();
731
+ if (bound.some(isSelfDistance) && urn === viewer) throw selfProfileError();
732
+
733
+ const breadcrumbs = records
734
+ .filter((record) => !recordContradictsTarget(record, slug, urn))
735
+ .map(distanceValue)
736
+ .filter((value): value is string | number => value !== null);
737
+ const breadcrumbOther = breadcrumbs.filter((value) => !isSelfDistance(value));
738
+ const breadcrumbUnique = uniqueDistances(breadcrumbOther);
739
+ if (breadcrumbUnique.length === 1 && breadcrumbUnique[0] !== undefined) {
740
+ return breadcrumbUnique[0];
741
+ }
742
+ if (breadcrumbs.some(isSelfDistance) && (urn === viewer || breadcrumbOther.length === 0)) {
743
+ throw selfProfileError();
744
+ }
745
+ throw omittedDistanceError();
746
+ }
747
+
527
748
  export function projectLinkedInProfileContactBinding(input: {
528
749
  readonly profileHtml: unknown;
529
750
  readonly profileUrl: unknown;
@@ -536,6 +757,7 @@ export function projectLinkedInProfileContactBinding(input: {
536
757
  if (vanityRecords.length < 1) {
537
758
  throw new Error("LinkedIn contact-info profile page did not bind the requested vanity");
538
759
  }
760
+ if (vanityRecords.some(recordIsSelfView)) throw selfProfileError();
539
761
  const urns = new Set<string>();
540
762
  for (const record of vanityRecords) {
541
763
  const urn = profileUrnFromRecord(record);
@@ -547,7 +769,11 @@ export function projectLinkedInProfileContactBinding(input: {
547
769
  for (const record of records) {
548
770
  const urn = profileUrnFromRecord(record);
549
771
  const distance = distanceValue(record);
550
- if (urn === viewer || (distance !== null && isSelfDistance(distance))) {
772
+ if (
773
+ recordIsSelfView(record)
774
+ || urn === viewer
775
+ || (distance !== null && isSelfDistance(distance))
776
+ ) {
551
777
  sawSelf = true;
552
778
  }
553
779
  if (urn === null || urn === viewer || distance === null) continue;
@@ -567,17 +793,7 @@ export function projectLinkedInProfileContactBinding(input: {
567
793
  }
568
794
  const urn = urns.values().next().value!;
569
795
  if (urn === viewer) throw selfProfileError();
570
- const related = records.filter((record) =>
571
- vanityFromRecord(record) === target.slug || profileUrnFromRecord(record) === urn
572
- );
573
- const distances = related
574
- .map(distanceValue)
575
- .filter((value): value is string | number => value !== null);
576
- const unique = [...new Set(distances.map((value) => String(value)))];
577
- if (unique.length !== 1 || distances[0] === undefined) {
578
- throw new Error("LinkedIn contact-info profile page omitted or contradicted its relationship distance");
579
- }
580
- const distance = distances[0];
796
+ const distance = relationshipDistance(records, target.slug, urn, viewer);
581
797
  if (isSelfDistance(distance)) throw selfProfileError();
582
798
  if (!isFirstDegree(distance)) {
583
799
  throw new Error(
@@ -826,7 +1042,7 @@ function collectRecords(value: unknown): readonly JsonRecord[] {
826
1042
  while (stack.length > 0) {
827
1043
  const next = stack.pop()!;
828
1044
  nodes += 1;
829
- if (nodes > MAX_WALK_NODES || next.depth > 32) {
1045
+ if (nodes > MAX_WALK_NODES || next.depth > MAX_WALK_DEPTH) {
830
1046
  throw new Error("LinkedIn contact-info payload exceeded its traversal bound");
831
1047
  }
832
1048
  if (Array.isArray(next.value)) {
@@ -1,4 +1,5 @@
1
1
  import { canonicalJson, sha256 } from "./canonical-json";
2
+ import { assertOperationPermission, withOperationPermission } from "./operation-permission";
2
3
  import { loadAuthSnapshotIfPresent } from "./auth";
3
4
  import {
4
5
  createPortableProviderPluginCatalog,
@@ -239,8 +240,9 @@ export function readCachedPreparedCapability(
239
240
  options: ReadCapabilityOptions & { readonly registry: ProviderPluginRegistry },
240
241
  ): ReadProjectionCacheResult {
241
242
  const environment = options.environment ?? process.env;
243
+ assertOperationPermission(invocation, { environment, registry: options.registry });
242
244
  validateReadOptions(options);
243
- return withInvocationAuthorityAdmission(
245
+ const result = withInvocationAuthorityAdmission(
244
246
  invocation,
245
247
  environment,
246
248
  () => {
@@ -265,6 +267,8 @@ export function readCachedPreparedCapability(
265
267
  return cached;
266
268
  },
267
269
  );
270
+ assertOperationPermission(invocation, { environment, registry: options.registry });
271
+ return result;
268
272
  }
269
273
 
270
274
  export function readCachedCapability(
@@ -279,7 +283,7 @@ export function readCachedCapability(
279
283
  );
280
284
  }
281
285
 
282
- export async function revalidatePreparedCapability(
286
+ async function revalidatePreparedCapabilityCore(
283
287
  invocation: PreparedInvocation,
284
288
  options: PreparedReadOptions,
285
289
  cachedBeforeOverride?: ReadProjectionCacheResult | null,
@@ -467,6 +471,12 @@ export async function revalidatePreparedCapability(
467
471
  }
468
472
  }
469
473
 
474
+ export async function revalidatePreparedCapability(invocation: PreparedInvocation, options: PreparedReadOptions, cachedBeforeOverride?: ReadProjectionCacheResult | null): Promise<RevalidatedCapability> {
475
+ const environment = options.environment ?? process.env;
476
+ return withOperationPermission(invocation, { environment, registry: options.registry, ...(options.signal === undefined ? {} : { signal: options.signal }) },
477
+ () => revalidatePreparedCapabilityCore(invocation, options, cachedBeforeOverride));
478
+ }
479
+
470
480
  export async function revalidateCapability(
471
481
  request: CapabilityReadRequest,
472
482
  options: RevalidateCapabilityOptions = {},
package/src/runtime.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as Effect from "effect/Effect";
2
+ import { assertOperationPreparationPermission, checkOperationPermission, withOperationPermission, withUnmanagedOperationPermission, readOperationPolicy } from "./operation-permission";
3
+ import type { ApprovalTarget } from "./control/protocol";
2
4
  import { ConfirmedWritePlatform, makeConfirmedWritePlatform } from "./confirmed-write-platform";
3
5
  import { confirmedWriteProgram } from "./confirmed-write-program";
4
6
  import { runConfirmedWrite } from "./confirmed-write-runtime";
@@ -186,6 +188,7 @@ type InvocationPlanCommon = {
186
188
  readonly id: string;
187
189
  readonly hash: string;
188
190
  readonly kind: GhostgetAuth["kind"];
191
+ readonly incarnationHash?: string;
189
192
  };
190
193
  readonly duplicateRisk?: InvocationDuplicateRiskV1;
191
194
  readonly messagingComposite?: MessagingCompositeInvocationPlanV1;
@@ -1231,6 +1234,7 @@ export function prepareInvocation(
1231
1234
  authId?: string,
1232
1235
  environment: Readonly<Record<string, string | undefined>> = process.env,
1233
1236
  registry: ProviderPluginRegistry = providerPluginRegistry,
1237
+ permissionMode: "enforce" | "inspect" = "enforce",
1234
1238
  ): PreparedInvocation {
1235
1239
  const manifestResult = loadInstalledManifestWithRegistry(adapterId, environment, registry);
1236
1240
  if (!manifestResult.ok) throw new Error(`adapter ${adapterId} is invalid: ${manifestResult.issues.join("; ")}`);
@@ -1257,7 +1261,7 @@ export function prepareInvocation(
1257
1261
  );
1258
1262
  }
1259
1263
  const authority = authenticationPolicy.authority;
1260
- return revalidatePreparedInvocation({
1264
+ const invocation = revalidatePreparedInvocation({
1261
1265
  manifest: manifestResult.value,
1262
1266
  operationId,
1263
1267
  input: platformInput.value,
@@ -1265,6 +1269,8 @@ export function prepareInvocation(
1265
1269
  readProjectionAuthIdentityHash:
1266
1270
  publicWebSessionAuthorityIdentityHash(authority),
1267
1271
  }, registry).invocation;
1272
+ if (permissionMode === "enforce") assertOperationPreparationPermission(invocation, { environment, registry });
1273
+ return invocation;
1268
1274
  }
1269
1275
  const selectedAuthId = authId ?? adapterId;
1270
1276
  const preparedAuth = withSettledReadProjectionAuthAdmission(
@@ -1282,7 +1288,7 @@ export function prepareInvocation(
1282
1288
  });
1283
1289
  },
1284
1290
  );
1285
- return revalidatePreparedInvocation({
1291
+ const invocation = revalidatePreparedInvocation({
1286
1292
  manifest: manifestResult.value,
1287
1293
  operationId,
1288
1294
  input: platformInput.value,
@@ -1290,6 +1296,8 @@ export function prepareInvocation(
1290
1296
  readProjectionAuthIdentityHash:
1291
1297
  preparedAuth.readProjectionAuthIdentityHash,
1292
1298
  }, registry).invocation;
1299
+ if (permissionMode === "enforce") assertOperationPreparationPermission(invocation, { environment, registry });
1300
+ return invocation;
1293
1301
  }
1294
1302
 
1295
1303
  /**
@@ -1448,6 +1456,7 @@ export function createInvocationPlan(
1448
1456
  id: planAuth.id,
1449
1457
  hash: authHash(planAuth),
1450
1458
  kind: planAuth.kind,
1459
+ ...(invocation.readProjectionAuthIdentityHash === undefined ? {} : { incarnationHash: invocation.readProjectionAuthIdentityHash }),
1451
1460
  },
1452
1461
  };
1453
1462
  const portablePluginContract = pluginResolution?.portableIdentity ?? null;
@@ -1678,6 +1687,12 @@ function planRunJournalContract(plan: InvocationPlan): RunJournal["contract"] {
1678
1687
  : { transport: contract.transport, hash: contract.hash };
1679
1688
  }
1680
1689
 
1690
+ /** Recovery v1 records the durable account selection. The full lifetime binding
1691
+ * remains in the successor scope hash and is revalidated before confirmation. */
1692
+ function planRecoveryAuth(plan: InvocationPlan): RunJournal["auth"] {
1693
+ return { id: plan.auth.id, hash: plan.auth.hash, kind: plan.auth.kind };
1694
+ }
1695
+
1681
1696
  function planFileInputs(input: OperationInput): readonly FileInputValue[] {
1682
1697
  const files: FileInputValue[] = [];
1683
1698
  for (const value of Object.values(input)) {
@@ -1753,7 +1768,7 @@ function resolveInvocationDuplicateRisk(
1753
1768
  || journal.operation !== plan.operation
1754
1769
  || journal.risk !== plan.risk
1755
1770
  || journal.inputHash !== plan.inputHash
1756
- || canonicalJson(journal.auth) !== canonicalJson(plan.auth)
1771
+ || canonicalJson(journal.auth) !== canonicalJson(planRecoveryAuth(plan))
1757
1772
  || canonicalJson(journal.contract)
1758
1773
  !== canonicalJson(planRunJournalContract(plan))
1759
1774
  ) {
@@ -1786,7 +1801,7 @@ function resolveInvocationDuplicateRisk(
1786
1801
  || capsule.risk !== plan.risk
1787
1802
  || capsule.inputHash !== plan.inputHash
1788
1803
  || canonicalJson(capsule.input) !== canonicalJson(plan.input)
1789
- || canonicalJson(capsule.auth) !== canonicalJson(plan.auth)
1804
+ || canonicalJson(capsule.auth) !== canonicalJson(planRecoveryAuth(plan))
1790
1805
  || canonicalJson(capsule.contract) !== canonicalJson(planRecoveryContract(plan))
1791
1806
  ) {
1792
1807
  throw new Error(
@@ -2433,7 +2448,8 @@ function parseStoredPlan(value: unknown): StoredPlan {
2433
2448
  ) throw new Error("stored plan adapter is malformed");
2434
2449
  const auth = raw.auth;
2435
2450
  if (
2436
- !hasExactKeys(auth, ["id", "hash", "kind"])
2451
+ !hasExactKeys(auth, ["id", "hash", "kind", ...(Object.hasOwn(auth, "incarnationHash") ? ["incarnationHash"] : [])])
2452
+ || (Object.hasOwn(auth, "incarnationHash") && (typeof auth.incarnationHash !== "string" || !/^[a-f0-9]{64}$/u.test(auth.incarnationHash)))
2437
2453
  || typeof auth.id !== "string"
2438
2454
  || !/^[a-z][a-z0-9-]{0,47}$/u.test(auth.id)
2439
2455
  || typeof auth.hash !== "string"
@@ -2550,7 +2566,7 @@ function parseStoredPlan(value: unknown): StoredPlan {
2550
2566
  input,
2551
2567
  inputHash,
2552
2568
  dispatches,
2553
- auth: { id: auth.id, hash: auth.hash, kind: auth.kind },
2569
+ auth: { id: auth.id, hash: auth.hash, kind: auth.kind, ...(typeof auth.incarnationHash === "string" ? { incarnationHash: auth.incarnationHash } : {}) },
2554
2570
  ...(Object.hasOwn(raw, "duplicateRisk")
2555
2571
  ? { duplicateRisk: parseInvocationDuplicateRisk(raw.duplicateRisk) }
2556
2572
  : {}),
@@ -2977,14 +2993,41 @@ function validateFreshPlan(
2977
2993
  } else if (auth.kind === "oauth-token-file") {
2978
2994
  throw new Error("browser authentication changed after preview; preview the action again");
2979
2995
  }
2996
+ const incarnation = withSettledReadProjectionAuthAdmission(auth.id, environment, () => {
2997
+ const current = loadAuth(auth.id, environment);
2998
+ if (authHash(current) !== authHash(auth)) throw new Error("authentication changed during confirmation preparation");
2999
+ return projectionAuthIdentityHash(auth.id, authHash(auth), environment);
3000
+ });
3001
+ if ((plan.auth.incarnationHash !== undefined && plan.auth.incarnationHash !== incarnation)
3002
+ || (plan.auth.incarnationHash === undefined && readOperationPolicy(environment).managed)) {
3003
+ throw new Error("authentication lifetime changed or predates managed permissions; preview the action again");
3004
+ }
2980
3005
  return revalidatePreparedInvocation({
2981
3006
  manifest,
2982
3007
  operationId: plan.operation,
2983
3008
  input: platformInput.value,
2984
3009
  auth,
3010
+ readProjectionAuthIdentityHash: incarnation,
2985
3011
  }, registry).invocation;
2986
3012
  }
2987
3013
 
3014
+ /** Trusted control-plane inspection. This only prepares; execution gates remain mandatory. */
3015
+ export function prepareOperationApprovalInvocation(
3016
+ target: Extract<ApprovalTarget, { readonly kind: "provider" }>,
3017
+ options: { readonly environment: Readonly<Record<string, string | undefined>>; readonly registry: ProviderPluginRegistry },
3018
+ ): { readonly invocation: PreparedInvocation; readonly stored: StoredPlan | null } {
3019
+ if (target.planDigest === null) return {
3020
+ invocation: prepareInvocation(target.adapterId, target.operationId, target.input, target.authId ?? undefined, options.environment, options.registry, "inspect"), stored: null,
3021
+ };
3022
+ const stored = loadInvocationPlan(target.planDigest, options.environment);
3023
+ if (stored.plan.adapter.id !== target.adapterId || stored.plan.operation !== target.operationId || stored.plan.auth.id !== target.authId) {
3024
+ throw new Error("approval target does not match its exact saved plan");
3025
+ }
3026
+ const invocation = validateFreshPlan(stored, options.environment, new Date(), options.registry,
3027
+ (id, environment = options.environment) => loadInstalledManifestWithRegistry(id, environment, options.registry));
3028
+ return { invocation, stored };
3029
+ }
3030
+
2988
3031
 
2989
3032
 
2990
3033
 
@@ -4381,6 +4424,7 @@ async function runPreparedReadCore(invocation: PreparedInvocation, planDigest: s
4381
4424
 
4382
4425
  },
4383
4426
  execute: async () => {
4427
+ await checkOperationPermission(invocation, { environment: options.environment, registry, ...(options.signal === undefined ? {} : { signal: options.signal }) });
4384
4428
  if (options.preflightFailure !== undefined) throw options.preflightFailure;
4385
4429
  return providerOperation
4386
4430
  ? await (options.executeProvider ?? executeProviderOperation)(
@@ -4524,7 +4568,7 @@ async function runPreparedReadCore(invocation: PreparedInvocation, planDigest: s
4524
4568
 
4525
4569
 
4526
4570
 
4527
- async function runPrepared(
4571
+ async function runPreparedCore(
4528
4572
  invocation: PreparedInvocation,
4529
4573
  planDigest: string | null,
4530
4574
  options: RunPreparedOptions,
@@ -4593,6 +4637,12 @@ async function runPrepared(
4593
4637
  );
4594
4638
  }
4595
4639
 
4640
+ async function runPrepared(invocation: PreparedInvocation, planDigest: string | null, options: RunPreparedOptions): Promise<InvocationResult> {
4641
+ if (!readOperationPolicy(options.environment).managed) return withUnmanagedOperationPermission(options.environment, () => runPreparedCore(invocation, planDigest, options));
4642
+ return withOperationPermission(invocation, { environment: options.environment, registry: options.registry ?? providerPluginRegistry,
4643
+ ...(options.signal === undefined ? {} : { signal: options.signal }) }, () => runPreparedCore(invocation, planDigest, options));
4644
+ }
4645
+
4596
4646
  export async function executeReadInvocation(
4597
4647
  invocation: PreparedInvocation,
4598
4648
  options: {
@@ -4628,7 +4678,7 @@ export async function executeReadInvocation(
4628
4678
  });
4629
4679
  }
4630
4680
 
4631
- export async function confirmInvocation(
4681
+ async function confirmInvocationCore(
4632
4682
  digest: string,
4633
4683
  options: {
4634
4684
  readonly headed: boolean;
@@ -4681,6 +4731,17 @@ export async function confirmInvocation(
4681
4731
  }, options))));
4682
4732
  }
4683
4733
 
4734
+ export async function confirmInvocation(digest: string, options: Parameters<typeof confirmInvocationCore>[1]): Promise<InvocationResult> {
4735
+ const environment = options.environment ?? process.env;
4736
+ if (!readOperationPolicy(environment).managed) return withUnmanagedOperationPermission(environment, () => confirmInvocationCore(digest, options));
4737
+ const registry = options.registry ?? providerPluginRegistry;
4738
+ const stored = loadInvocationPlan(digest, environment);
4739
+ const invocation = validateFreshPlan(stored, environment, options.now ?? new Date(), registry,
4740
+ options.loadManifest ?? ((id, selected = environment) => loadInstalledManifestWithRegistry(id, selected, registry)));
4741
+ return withOperationPermission(invocation, { environment, registry, plan: stored, ...(options.signal === undefined ? {} : { signal: options.signal }) },
4742
+ () => confirmInvocationCore(digest, options));
4743
+ }
4744
+
4684
4745
  export type MessagingConfirmationResult = {
4685
4746
  readonly run: MessagingRunV1;
4686
4747
  readonly receipt: MessagingRunReceipt;
@@ -4728,7 +4789,7 @@ function terminalizeMessagingRecovery(
4728
4789
  }
4729
4790
 
4730
4791
  /** Confirm one composite messaging preview under one durable ownership claim. */
4731
- export async function confirmMessagingInvocation(
4792
+ async function confirmMessagingInvocationCore(
4732
4793
  digest: string,
4733
4794
  options: {
4734
4795
  readonly environment?: Readonly<Record<string, string | undefined>>;
@@ -4857,6 +4918,17 @@ export async function confirmMessagingInvocation(
4857
4918
  }
4858
4919
  }
4859
4920
 
4921
+ export async function confirmMessagingInvocation(digest: string, options: NonNullable<Parameters<typeof confirmMessagingInvocationCore>[1]> = {}): Promise<MessagingConfirmationResult> {
4922
+ const environment = options.environment ?? process.env;
4923
+ if (!readOperationPolicy(environment).managed) return withUnmanagedOperationPermission(environment, () => confirmMessagingInvocationCore(digest, options));
4924
+ const registry = options.registry ?? providerPluginRegistry;
4925
+ const stored = loadInvocationPlan(digest, environment);
4926
+ const invocation = validateFreshPlan(stored, environment, options.now ?? new Date(), registry,
4927
+ options.loadManifest ?? ((id, selected = environment) => loadInstalledManifestWithRegistry(id, selected, registry)));
4928
+ return withOperationPermission(invocation, { environment, registry, plan: stored, ...(options.signal === undefined ? {} : { signal: options.signal }) },
4929
+ () => confirmMessagingInvocationCore(digest, options));
4930
+ }
4931
+
4860
4932
  export function readRunReceipt(
4861
4933
  runId: string,
4862
4934
  environment: Readonly<Record<string, string | undefined>> = process.env,
@@ -41,11 +41,13 @@ const stateDirectories = new Set([
41
41
  "auth",
42
42
  "browser-snapshots",
43
43
  "captures",
44
+ "control",
44
45
  "derivations",
45
46
  "idempotency",
46
47
  "linked-device-stores",
47
48
  "messaging",
48
49
  "omni-read-projections",
50
+ "operation-permissions",
49
51
  "plan-assets",
50
52
  "plans",
51
53
  "provider-plugin-state",