@hraness/ghostget 0.17.6 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +16 -9
- package/dist/apple-photos-client.js +1 -1
- package/dist/beeper-client.js +1 -1
- package/dist/{index-cytf8d9p.js → index-9wca02er.js} +1 -1
- package/docs/control-panel.md +147 -0
- package/package.json +45 -6
- package/skills/ghostget/SKILL.md +3 -1
- package/skills/ghostget/references/control-panel.md +43 -0
- package/skills/ghostget/references/install.md +5 -5
- package/skills/ghostget/references/linkedin-adapter.md +17 -3
- package/skills/ghostget/references/platform-patterns.md +1 -1
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
- package/src/auth.ts +35 -1
- package/src/beeper-client-types.ts +1 -1
- package/src/cli.ts +18 -0
- package/src/confirmed-write-platform.ts +16 -1
- package/src/control/account-revision.ts +16 -0
- package/src/control/activity.ts +104 -0
- package/src/control/approval-broker.ts +59 -0
- package/src/control/approval-client.ts +49 -0
- package/src/control/bundled-interfaces.ts +20 -0
- package/src/control/cli.ts +20 -0
- package/src/control/connections.ts +87 -0
- package/src/control/credential-helper.ts +152 -0
- package/src/control/helper.ts +79 -0
- package/src/control/interface-cli.ts +22 -0
- package/src/control/interface-json.ts +94 -0
- package/src/control/interface-schema.ts +120 -0
- package/src/control/interfaces.ts +438 -0
- package/src/control/protocol.ts +182 -0
- package/src/control/service.ts +104 -0
- package/src/control/validation.ts +103 -0
- package/src/control/vault.ts +105 -0
- package/src/control/web-gateway.ts +62 -0
- package/src/control/web-policy.ts +56 -0
- package/src/ghostget.ts +2 -0
- package/src/messaging-runtime.ts +3 -0
- package/src/oauth-google.ts +11 -5
- package/src/omni-runtime.ts +18 -3
- package/src/operation-permission-store.ts +92 -0
- package/src/operation-permission.ts +308 -0
- package/src/pinned-https.ts +5 -0
- package/src/provider-http.ts +11 -3
- package/src/provider-plugin-contract-identity.ts +2 -2
- package/src/provider-plugin-import-analysis.ts +52 -0
- package/src/provider-plugin-module-analysis.ts +21 -1
- package/src/provider-plugin-registry.ts +4 -8
- package/src/provider-plugin.ts +4 -8
- package/src/providers/linkedin-web-contact.ts +237 -21
- package/src/read-client.ts +12 -2
- package/src/runtime.ts +81 -9
- package/src/state-helper.ts +2 -0
- package/src/storage.ts +71 -1
- package/src/usage.ts +6 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { canonicalJson, sha256 } from "./canonical-json";
|
|
3
|
+
import { createPrivateJsonIfAbsent, ensurePrivateStateDirectory, ghostgetStateHome, privateStateFilesMayExist, readPrivateStateFileIfPresent, writePrivateJsonIfUnchanged } from "./storage";
|
|
4
|
+
import type { PermissionDecision } from "./control/protocol";
|
|
5
|
+
|
|
6
|
+
export type PermissionEnvironment = Readonly<Record<string, string | undefined>>;
|
|
7
|
+
export type OperationPolicyEntry = Readonly<{ digest: string; decision: PermissionDecision }>;
|
|
8
|
+
export type OperationPolicy = Readonly<{ schemaVersion: 1; revision: number; entries: readonly OperationPolicyEntry[] }>;
|
|
9
|
+
export type OperationPolicySnapshot = Readonly<{ managed: boolean; revision: number; entries: readonly OperationPolicyEntry[]; contentSha256: string | null }>;
|
|
10
|
+
const MAX_POLICY_BYTES = 512 * 1024;
|
|
11
|
+
const MAX_ENTRIES = 4096;
|
|
12
|
+
const DIGEST = /^[a-f0-9]{64}$/u;
|
|
13
|
+
const marker = Object.freeze({ schemaVersion: 1, managed: true });
|
|
14
|
+
|
|
15
|
+
export class OperationPermissionError extends Error {
|
|
16
|
+
constructor(readonly code: "OPERATION_PERMISSION_DENIED" | "OPERATION_APPROVAL_REQUIRED" | "OPERATION_PERMISSION_CHANGED" | "OPERATION_POLICY_INVALID" | "OPERATION_APPROVAL_TOO_LARGE", message: string) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "OperationPermissionError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseOperationPolicy(value: unknown): OperationPolicy {
|
|
23
|
+
const fail = (): never => { throw new OperationPermissionError("OPERATION_POLICY_INVALID", "Operation permission policy is invalid; repair it in Ghostget before execution."); };
|
|
24
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return fail();
|
|
25
|
+
const record = value as Record<string, unknown>;
|
|
26
|
+
if (Object.keys(record).sort().join(",") !== "entries,revision,schemaVersion" || record.schemaVersion !== 1
|
|
27
|
+
|| !Number.isSafeInteger(record.revision) || (record.revision as number) < 1
|
|
28
|
+
|| !Array.isArray(record.entries) || record.entries.length > MAX_ENTRIES) return fail();
|
|
29
|
+
let previous = "";
|
|
30
|
+
const entries = record.entries.map((candidate: unknown): OperationPolicyEntry => {
|
|
31
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) return fail();
|
|
32
|
+
const entry = candidate as Record<string, unknown>;
|
|
33
|
+
if (Object.keys(entry).sort().join(",") !== "decision,digest" || typeof entry.digest !== "string" || !DIGEST.test(entry.digest)
|
|
34
|
+
|| entry.digest <= previous || (entry.decision !== "allow" && entry.decision !== "deny" && entry.decision !== "ask")) return fail();
|
|
35
|
+
previous = entry.digest;
|
|
36
|
+
return Object.freeze({ digest: entry.digest, decision: entry.decision });
|
|
37
|
+
});
|
|
38
|
+
return Object.freeze({ schemaVersion: 1, revision: record.revision as number, entries: Object.freeze(entries) });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function paths(environment: PermissionEnvironment) {
|
|
42
|
+
const directory = join(ghostgetStateHome(environment), "operation-permissions");
|
|
43
|
+
return { directory, marker: join(directory, "managed.json"), policy: join(directory, "policy.json") };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readOperationPolicy(environment: PermissionEnvironment = process.env): OperationPolicySnapshot {
|
|
47
|
+
try {
|
|
48
|
+
if (!privateStateFilesMayExist("operation-permissions", ["managed.json", "policy.json"], environment)) {
|
|
49
|
+
return Object.freeze({ managed: false, revision: 0, entries: Object.freeze([]), contentSha256: null });
|
|
50
|
+
}
|
|
51
|
+
const selected = paths(environment);
|
|
52
|
+
const markerText = readPrivateStateFileIfPresent(selected.marker, 256, "operation policy marker", environment);
|
|
53
|
+
if (markerText !== null && markerText !== `${canonicalJson(marker)}\n`) throw new Error("marker");
|
|
54
|
+
const text = readPrivateStateFileIfPresent(selected.policy, MAX_POLICY_BYTES, "operation permission policy", environment);
|
|
55
|
+
if (text === null) {
|
|
56
|
+
if (markerText !== null) throw new Error("missing policy");
|
|
57
|
+
return Object.freeze({ managed: false, revision: 0, entries: Object.freeze([]), contentSha256: null });
|
|
58
|
+
}
|
|
59
|
+
const policy = parseOperationPolicy(JSON.parse(text) as unknown);
|
|
60
|
+
if (text !== `${canonicalJson(policy)}\n`) throw new Error("noncanonical policy");
|
|
61
|
+
return Object.freeze({ managed: true, revision: policy.revision, entries: policy.entries, contentSha256: sha256(text) });
|
|
62
|
+
} catch {
|
|
63
|
+
throw new OperationPermissionError("OPERATION_POLICY_INVALID", "Operation permission state is missing or invalid; execution is blocked until it is repaired.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function enableOperationPermissions(expectedRevision: number, environment: PermissionEnvironment = process.env): OperationPolicySnapshot {
|
|
68
|
+
const snapshot = readOperationPolicy(environment);
|
|
69
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== snapshot.revision) throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Operation policy changed; refresh Ghostget before editing it.");
|
|
70
|
+
if (snapshot.managed) return snapshot;
|
|
71
|
+
const selected = paths(environment);
|
|
72
|
+
ensurePrivateStateDirectory(selected.directory, environment);
|
|
73
|
+
// Publishing the marker first deliberately makes interrupted opt-in fail closed.
|
|
74
|
+
const created = createPrivateJsonIfAbsent(selected.marker, marker, { environment });
|
|
75
|
+
if (!created.created) throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Another control session enabled operation permissions.");
|
|
76
|
+
if (!createPrivateJsonIfAbsent(selected.policy, { schemaVersion: 1, revision: 1, entries: [] }, { environment }).created) {
|
|
77
|
+
throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Operation policy appeared concurrently; refresh Ghostget.");
|
|
78
|
+
}
|
|
79
|
+
return readOperationPolicy(environment);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function setOperationPolicyEntry(digest: string, decision: PermissionDecision, expectedRevision: number, environment: PermissionEnvironment = process.env): OperationPolicySnapshot {
|
|
83
|
+
if (!DIGEST.test(digest) || !["allow", "deny", "ask"].includes(decision)) throw new OperationPermissionError("OPERATION_POLICY_INVALID", "Operation permission update is invalid.");
|
|
84
|
+
const snapshot = readOperationPolicy(environment);
|
|
85
|
+
if (!snapshot.managed || snapshot.contentSha256 === null || !Number.isSafeInteger(expectedRevision) || snapshot.revision !== expectedRevision
|
|
86
|
+
|| snapshot.revision >= Number.MAX_SAFE_INTEGER) throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Enable operation permissions or refresh the policy before editing it.");
|
|
87
|
+
const entries = [...snapshot.entries.filter(entry => entry.digest !== digest), { digest, decision }].sort((a, b) => a.digest.localeCompare(b.digest));
|
|
88
|
+
const policy = parseOperationPolicy({ schemaVersion: 1, revision: snapshot.revision + 1, entries });
|
|
89
|
+
if (Buffer.byteLength(canonicalJson(policy)) > MAX_POLICY_BYTES - 1) throw new OperationPermissionError("OPERATION_POLICY_INVALID", "Operation policy exceeds its size limit.");
|
|
90
|
+
if (!writePrivateJsonIfUnchanged(paths(environment).policy, policy, { expectedCurrentContentSha256: snapshot.contentSha256 })) throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Operation policy changed; refresh Ghostget before editing it.");
|
|
91
|
+
return readOperationPolicy(environment);
|
|
92
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { canonicalJson, sha256 } from "./canonical-json";
|
|
3
|
+
import { loadAuth, parseAuth } from "./auth";
|
|
4
|
+
import { isLocalCliOperation, isProviderOperation, isWebSessionOperation, manifestHash, parseRuntimeManifest, type GhostgetManifest, type OperationInput } from "./model";
|
|
5
|
+
import { providerContractHash, getProviderContract } from "./provider-contracts";
|
|
6
|
+
import { getWebSessionContract, webSessionContractHash } from "./web-session-contracts";
|
|
7
|
+
import { getLocalCliContract, localCliContractHash } from "./local-cli-contracts";
|
|
8
|
+
import { requireProviderPluginAuth } from "./provider-plugin-auth";
|
|
9
|
+
import type { ProviderPluginRegistry, ProviderPluginOperationResolutionV1 } from "./provider-plugin-registry";
|
|
10
|
+
import { ghostgetStateHome, loadInstalledManifest } from "./storage";
|
|
11
|
+
import { projectionAuthIdentityHash, withSettledReadProjectionAuthAdmission } from "./read-projections";
|
|
12
|
+
import { publicWebSessionAuthorityIdentityHash, webSessionAuthenticationPolicy, type InvocationAuthority } from "./web-session-authentication-policy";
|
|
13
|
+
import type { PreparedInvocation, StoredPlan } from "./runtime";
|
|
14
|
+
import { summarizePlanFile } from "./plan-assets";
|
|
15
|
+
import type { ApprovalTarget, CheckedApproval, JsonValue, PermissionDecision } from "./control/protocol";
|
|
16
|
+
import { OperationPermissionError, readOperationPolicy, setOperationPolicyEntry, type PermissionEnvironment, type OperationPolicySnapshot } from "./operation-permission-store";
|
|
17
|
+
export { OperationPermissionError, readOperationPolicy, enableOperationPermissions } from "./operation-permission-store";
|
|
18
|
+
|
|
19
|
+
type Options = { readonly environment: PermissionEnvironment; readonly registry: ProviderPluginRegistry; readonly signal?: AbortSignal };
|
|
20
|
+
type ProviderTarget = Extract<ApprovalTarget, { readonly kind: "provider" }>;
|
|
21
|
+
type ApprovalLease = Readonly<{ id: string; digest: string }>;
|
|
22
|
+
export type OperationPermissionIdentity = Readonly<{
|
|
23
|
+
schemaVersion: 1; pluginId: string; transport: string; surfaceId: string; operation: string; contractVersion: number;
|
|
24
|
+
adapterId: string; manifestHash: string; authId: string; authIncarnation: string; contractHash: string; closureHash: string; portableHash: string | null;
|
|
25
|
+
}>;
|
|
26
|
+
export type OperationPermissionDescription = Readonly<{
|
|
27
|
+
digest: string; decision: PermissionDecision | "unmanaged"; revision: number; coordinate: OperationPermissionIdentity;
|
|
28
|
+
manifest: GhostgetManifest; resolution: ProviderPluginOperationResolutionV1; auth: InvocationAuthority;
|
|
29
|
+
}>;
|
|
30
|
+
type Admission = Readonly<{ capability: string; requestDigest: string; revision: number; inputs: ReadonlySet<string>; planDigest: string | null; lease: ApprovalLease | null; stateHome: string }>;
|
|
31
|
+
const admissions = new AsyncLocalStorage<readonly Admission[]>();
|
|
32
|
+
const checkedCapabilities = new WeakMap<CheckedApproval, Readonly<{ capability: string; target: string }>>();
|
|
33
|
+
const MAX_APPROVAL_BYTES = 240 * 1024;
|
|
34
|
+
|
|
35
|
+
function changed(): never { throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Operation, account, input, or permissions changed; request fresh authorization."); }
|
|
36
|
+
function denied(): never { throw new OperationPermissionError("OPERATION_PERMISSION_DENIED", "This operation is denied in Ghostget. Review its account and operation permissions in the app."); }
|
|
37
|
+
function approvalRequired(): never { throw new OperationPermissionError("OPERATION_APPROVAL_REQUIRED", "This operation requires human approval in Ghostget. Run a live invocation with the app open; cached reads never wait for approval."); }
|
|
38
|
+
|
|
39
|
+
function resolutionFor(manifest: GhostgetManifest, operationId: string, registry: ProviderPluginRegistry): ProviderPluginOperationResolutionV1 {
|
|
40
|
+
const operation = manifest.operations[operationId];
|
|
41
|
+
if (operation === undefined) throw new Error("Operation is not installed.");
|
|
42
|
+
if (isProviderOperation(operation)) return registry.requireOperationDefinition("provider-api", operation.provider.provider, operation.provider.action, operation.provider.contractVersion);
|
|
43
|
+
if (isLocalCliOperation(operation)) return registry.requireOperationDefinition("local-cli", operation.localCli.surface, operation.localCli.action, operation.localCli.contractVersion);
|
|
44
|
+
if (isWebSessionOperation(operation)) return registry.requireOperationDefinition(registry.requireSessionRoute(operation.webSession.site).transport, operation.webSession.site, operation.webSession.action, operation.webSession.contractVersion);
|
|
45
|
+
throw new OperationPermissionError("OPERATION_PERMISSION_DENIED", "This legacy transport does not support managed operation permissions.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function currentManifest(adapterId: string, options: Options): GhostgetManifest {
|
|
49
|
+
const owned = options.registry.resolveOwnedManifest(adapterId);
|
|
50
|
+
const selected = owned === undefined ? loadInstalledManifest(adapterId, options.environment, options.registry) : parseRuntimeManifest(owned, options.registry);
|
|
51
|
+
if (!selected.ok) throw new Error("The selected adapter is unavailable or invalid.");
|
|
52
|
+
return selected.value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function publicAuthority(manifest: GhostgetManifest, operationId: string, resolution: ProviderPluginOperationResolutionV1): InvocationAuthority | null {
|
|
56
|
+
const operation = manifest.operations[operationId]!;
|
|
57
|
+
if (!isWebSessionOperation(operation)) return null;
|
|
58
|
+
const policy = webSessionAuthenticationPolicy({
|
|
59
|
+
adapterId: manifest.id, operationId, recipe: operation.webSession, pluginSourceKind: resolution.plugin.sourceKind,
|
|
60
|
+
portable: resolution.portableIdentity !== null, risk: resolution.operation.risk, state: resolution.operation.state, dispatch: resolution.operation.dispatch,
|
|
61
|
+
...(resolution.contractVersion === resolution.operation.contractVersion && resolution.operation.access !== undefined ? { access: resolution.operation.access } : {}),
|
|
62
|
+
});
|
|
63
|
+
return policy.kind === "public" ? policy.authority : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type AccountIdentity = Readonly<{ auth: ReturnType<typeof loadAuth>; incarnation: string }>;
|
|
67
|
+
type Inspection = {
|
|
68
|
+
policy: OperationPolicySnapshot;
|
|
69
|
+
manifests: Map<string, GhostgetManifest>;
|
|
70
|
+
accounts: Map<string, AccountIdentity | null>;
|
|
71
|
+
closures: Map<ProviderPluginOperationResolutionV1["binding"], string>;
|
|
72
|
+
contracts: Map<string, string>;
|
|
73
|
+
};
|
|
74
|
+
function accountIdentity(id: string, options: Options): AccountIdentity {
|
|
75
|
+
return withSettledReadProjectionAuthAdmission(id, options.environment, () => {
|
|
76
|
+
const auth = loadAuth(id, options.environment);
|
|
77
|
+
return { auth, incarnation: projectionAuthIdentityHash(auth.id, sha256(canonicalJson(auth)), options.environment) };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function inspectedAccount(id: string, options: Options, inspection: Inspection): AccountIdentity {
|
|
81
|
+
if (!inspection.accounts.has(id)) {
|
|
82
|
+
try { inspection.accounts.set(id, accountIdentity(id, options)); } catch { inspection.accounts.set(id, null); }
|
|
83
|
+
}
|
|
84
|
+
const account = inspection.accounts.get(id);
|
|
85
|
+
if (account === null || account === undefined) throw new Error("The selected account is unavailable.");
|
|
86
|
+
return account;
|
|
87
|
+
}
|
|
88
|
+
function describe(adapterId: string, operationId: string, authId: string | null, options: Options, inspection: Inspection): OperationPermissionDescription {
|
|
89
|
+
const { policy } = inspection;
|
|
90
|
+
const manifest = inspection.manifests.get(adapterId) ?? currentManifest(adapterId, options);
|
|
91
|
+
inspection.manifests.set(adapterId, manifest);
|
|
92
|
+
const resolution = resolutionFor(manifest, operationId, options.registry);
|
|
93
|
+
const operation = manifest.operations[operationId]!;
|
|
94
|
+
const publicAuth = publicAuthority(manifest, operationId, resolution);
|
|
95
|
+
if (publicAuth !== null && authId !== null) throw new Error("Public operations do not accept an account.");
|
|
96
|
+
if (publicAuth === null && authId === null) throw new Error("Select an explicit account to inspect private operation permissions.");
|
|
97
|
+
const selectedId = authId ?? adapterId;
|
|
98
|
+
const authority = publicAuth !== null
|
|
99
|
+
? { auth: publicAuth, incarnation: publicWebSessionAuthorityIdentityHash(publicAuth as Parameters<typeof publicWebSessionAuthorityIdentityHash>[0]) }
|
|
100
|
+
: inspectedAccount(selectedId, options, inspection);
|
|
101
|
+
if (publicAuth === null) {
|
|
102
|
+
requireProviderPluginAuth(resolution.binding, authority.auth as ReturnType<typeof loadAuth>);
|
|
103
|
+
}
|
|
104
|
+
const contractKey = canonicalJson([resolution.binding.transport, resolution.binding.surfaceId, resolution.operation.name, resolution.contractVersion]);
|
|
105
|
+
const contractHash = inspection.contracts.get(contractKey) ?? (isProviderOperation(operation) ? providerContractHash(getProviderContract(operation.provider, options.registry), options.registry)
|
|
106
|
+
: isWebSessionOperation(operation) ? webSessionContractHash(getWebSessionContract(operation.webSession, options.registry), options.registry)
|
|
107
|
+
: isLocalCliOperation(operation) ? localCliContractHash(getLocalCliContract(operation.localCli, options.registry), options.registry) : denied());
|
|
108
|
+
inspection.contracts.set(contractKey, contractHash);
|
|
109
|
+
const closureHash = inspection.closures.get(resolution.binding) ?? options.registry.implementationClosureHash(resolution.binding);
|
|
110
|
+
inspection.closures.set(resolution.binding, closureHash);
|
|
111
|
+
const coordinate: OperationPermissionIdentity = Object.freeze({
|
|
112
|
+
schemaVersion: 1, pluginId: resolution.plugin.id, transport: resolution.binding.transport, surfaceId: resolution.binding.surfaceId,
|
|
113
|
+
operation: resolution.operation.name, contractVersion: resolution.contractVersion, adapterId, manifestHash: manifestHash(manifest),
|
|
114
|
+
authId: authority.auth.id, authIncarnation: authority.incarnation, contractHash,
|
|
115
|
+
closureHash, portableHash: resolution.portableIdentity === null ? null : sha256(canonicalJson(resolution.portableIdentity)),
|
|
116
|
+
});
|
|
117
|
+
const digest = sha256(canonicalJson(coordinate));
|
|
118
|
+
return Object.freeze({ digest, coordinate, revision: policy.revision, decision: policy.managed ? policy.entries.find(entry => entry.digest === digest)?.decision ?? "deny" : "unmanaged", manifest, resolution, auth: authority.auth });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function inspection(options: Options): Inspection {
|
|
122
|
+
return { policy: readOperationPolicy(options.environment), manifests: new Map(), accounts: new Map(), closures: new Map(), contracts: new Map() };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function describeOperationPermission(adapterId: string, operationId: string, authId: string | null, options: Options): OperationPermissionDescription {
|
|
126
|
+
return describe(adapterId, operationId, authId, options, inspection(options));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Snapshot-local reuse keeps control-panel inspection proportional to unique accounts and adapters. Never reuse this snapshot to authorize a later request. */
|
|
130
|
+
export function describeOperationPermissions(requests: readonly Readonly<{ adapterId: string; operationId: string; authId: string | null }>[], options: Options): readonly (OperationPermissionDescription | null)[] {
|
|
131
|
+
const unavailable = () => Object.freeze(requests.map(() => null));
|
|
132
|
+
let snapshot: Inspection;
|
|
133
|
+
try { snapshot = inspection(options); } catch { return unavailable(); }
|
|
134
|
+
const results = requests.map(request => {
|
|
135
|
+
try { return describe(request.adapterId, request.operationId, request.authId, options, snapshot); } catch { return null; }
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
for (const [id, manifest] of snapshot.manifests) if (manifestHash(currentManifest(id, options)) !== manifestHash(manifest)) return unavailable();
|
|
139
|
+
for (const [id, identity] of snapshot.accounts) if (identity !== null && canonicalJson(accountIdentity(id, options)) !== canonicalJson(identity)) return unavailable();
|
|
140
|
+
for (const [binding, closure] of snapshot.closures) if (options.registry.implementationClosureHash(binding) !== closure) return unavailable();
|
|
141
|
+
if (canonicalJson(readOperationPolicy(options.environment)) !== canonicalJson(snapshot.policy)) return unavailable();
|
|
142
|
+
} catch { return unavailable(); }
|
|
143
|
+
return Object.freeze(results);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function setOperationPermission(request: {
|
|
147
|
+
readonly adapterId: string; readonly operationId: string; readonly authId: string | null; readonly decision: PermissionDecision;
|
|
148
|
+
readonly expectedRevision: number; readonly expectedCapabilityDigest: string;
|
|
149
|
+
}, options: Options) {
|
|
150
|
+
const description = describeOperationPermission(request.adapterId, request.operationId, request.authId, options);
|
|
151
|
+
if (description.digest !== request.expectedCapabilityDigest || description.revision !== request.expectedRevision) return changed();
|
|
152
|
+
return setOperationPolicyEntry(description.digest, request.decision, request.expectedRevision, options.environment);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function describeInvocation(invocation: PreparedInvocation, options: Options): OperationPermissionDescription {
|
|
156
|
+
const description = describeOperationPermission(invocation.manifest.id, invocation.operationId, invocation.auth.kind === "public-web-session" ? null : invocation.auth.id, options);
|
|
157
|
+
if (manifestHash(invocation.manifest) !== description.coordinate.manifestHash || canonicalJson(invocation.auth) !== canonicalJson(description.auth)
|
|
158
|
+
|| invocation.readProjectionAuthIdentityHash !== description.coordinate.authIncarnation) return changed();
|
|
159
|
+
if (invocation.auth.kind !== "public-web-session") parseAuth(invocation.auth);
|
|
160
|
+
return description;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function activeAdmission(description: OperationPermissionDescription, input: OperationInput, options: Options): Admission | undefined {
|
|
164
|
+
const hash = sha256(canonicalJson(input));
|
|
165
|
+
const stateHome = ghostgetStateHome(options.environment);
|
|
166
|
+
return admissions.getStore()?.findLast(admission => admission.stateHome === stateHome && admission.capability === description.digest && admission.revision === description.revision && admission.inputs.has(hash));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Cheap deny check for construction and identity queries. Inspection by the control plane uses describeOperationPermission instead. */
|
|
170
|
+
export function assertOperationPreparationPermission(invocation: PreparedInvocation, options: Options): void {
|
|
171
|
+
if (!readOperationPolicy(options.environment).managed) return;
|
|
172
|
+
if (describeInvocation(invocation, options).decision === "deny") denied();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Synchronous disclosure never opens IPC or creates an approval request. */
|
|
176
|
+
export function assertOperationPermission(invocation: PreparedInvocation, options: Options): void {
|
|
177
|
+
const policy = readOperationPolicy(options.environment);
|
|
178
|
+
if (!policy.managed) {
|
|
179
|
+
if (admissions.getStore()?.some(admission => admission.revision !== 0)) changed();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const description = describeInvocation(invocation, options);
|
|
183
|
+
if (admissions.getStore()?.some(admission => admission.capability === description.digest && admission.revision !== description.revision)) changed();
|
|
184
|
+
if (description.decision === "deny") denied();
|
|
185
|
+
if (description.decision === "ask" && activeAdmission(description, invocation.input, options) === undefined) approvalRequired();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function checkOperationPermission(invocation: PreparedInvocation, options: Options): Promise<void> {
|
|
189
|
+
assertOperationPermission(invocation, options);
|
|
190
|
+
if (!readOperationPolicy(options.environment).managed) return;
|
|
191
|
+
const description = describeInvocation(invocation, options);
|
|
192
|
+
const admission = activeAdmission(description, invocation.input, options);
|
|
193
|
+
if (admission?.lease !== null && admission?.lease !== undefined) {
|
|
194
|
+
const { checkApproval } = await import("./control/approval-client");
|
|
195
|
+
await checkApproval(admission.lease, { environment: options.environment, ...(options.signal === undefined ? {} : { signal: options.signal }) });
|
|
196
|
+
assertOperationPermission(invocation, options);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function inputForTarget(input: OperationInput): JsonValue {
|
|
201
|
+
return JSON.parse(canonicalJson(Object.fromEntries(Object.entries(input).map(([key, value]) => [key,
|
|
202
|
+
Array.isArray(value) ? value.map(item => typeof item === "object" ? item.reference : item)
|
|
203
|
+
: typeof value === "object" && "reference" in value ? value.reference : value,
|
|
204
|
+
])))) as JsonValue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function checkedApproval(invocation: PreparedInvocation, description: OperationPermissionDescription, stored: StoredPlan | null): CheckedApproval {
|
|
208
|
+
const previewOf = (input: OperationInput): unknown => Object.fromEntries(Object.entries(input).map(([key, value]) => [key,
|
|
209
|
+
Array.isArray(value) ? value.map(item => typeof item === "object" ? summarizePlanFile(item) : item)
|
|
210
|
+
: typeof value === "object" ? summarizePlanFile(value as Parameters<typeof summarizePlanFile>[0]) : value,
|
|
211
|
+
]));
|
|
212
|
+
const composite = stored?.plan.messagingComposite;
|
|
213
|
+
const previewInput = composite === undefined ? previewOf(invocation.input) : {
|
|
214
|
+
recipient: composite.recipient,
|
|
215
|
+
routeRef: composite.routeRef,
|
|
216
|
+
contextRef: composite.contextRef,
|
|
217
|
+
parts: composite.parts.map(part => ({ part: part.partId, text: part.text, input: previewOf(part.input) })),
|
|
218
|
+
};
|
|
219
|
+
const preview = canonicalJson(previewInput);
|
|
220
|
+
if (Buffer.byteLength(preview) > MAX_APPROVAL_BYTES) throw new OperationPermissionError("OPERATION_APPROVAL_TOO_LARGE", "This operation exceeds the human approval preview size limit; no request was executed.");
|
|
221
|
+
const digest = sha256(canonicalJson({ protocol: "ghostget.operation-approval/1", capability: description.digest, revision: description.revision,
|
|
222
|
+
inputHash: sha256(canonicalJson(invocation.input)), planDigest: stored?.digest ?? null, previewHash: sha256(preview) }));
|
|
223
|
+
return Object.freeze({ digest, revision: description.revision, decision: description.decision === "unmanaged" ? "allow" : description.decision,
|
|
224
|
+
kind: "provider", title: `${description.manifest.displayName}: ${invocation.operationId}`, account: invocation.auth.kind === "public-web-session" ? null : invocation.auth.id,
|
|
225
|
+
effect: description.manifest.operations[invocation.operationId]!.sideEffect, preview });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Recompute agent requests using current installed manifests/auth and encrypted plans. Caller summaries never grant authority. */
|
|
229
|
+
export async function checkProviderApproval(target: ProviderTarget, options: Options): Promise<CheckedApproval> {
|
|
230
|
+
const { prepareOperationApprovalInvocation } = await import("./runtime");
|
|
231
|
+
const { invocation, stored } = prepareOperationApprovalInvocation(target, options);
|
|
232
|
+
if (stored === null && invocation.manifest.operations[invocation.operationId]!.risk !== "R1") {
|
|
233
|
+
throw new OperationPermissionError("OPERATION_APPROVAL_REQUIRED", "This operation requires an exact saved confirmation plan before human approval.");
|
|
234
|
+
}
|
|
235
|
+
const description = describeInvocation(invocation, options);
|
|
236
|
+
const checked = checkedApproval(invocation, description, stored);
|
|
237
|
+
checkedCapabilities.set(checked, { capability: description.digest, target: canonicalJson(target) });
|
|
238
|
+
return checked;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A consumed plan is intentionally gone. Recheck retained admission authority, never revive or reload it. */
|
|
242
|
+
export async function recheckProviderApproval(target: ProviderTarget, checked: CheckedApproval, options: Options): Promise<CheckedApproval> {
|
|
243
|
+
const retained = checkedCapabilities.get(checked);
|
|
244
|
+
if (retained === undefined || retained.target !== canonicalJson(target)) return changed();
|
|
245
|
+
const current = describeOperationPermission(target.adapterId, target.operationId, target.authId, options);
|
|
246
|
+
if (current.digest !== retained.capability || current.revision !== checked.revision || current.decision !== "ask") return changed();
|
|
247
|
+
return checked;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Invocation-local permits cannot authorize another input, account, manifest or policy revision. */
|
|
251
|
+
export async function withOperationPermission<T>(invocation: PreparedInvocation, optionsValue: Options & { readonly plan?: StoredPlan }, work: () => Promise<T>): Promise<T> {
|
|
252
|
+
const options = { ...optionsValue, environment: Object.freeze({ ...optionsValue.environment }) };
|
|
253
|
+
const policy = readOperationPolicy(options.environment);
|
|
254
|
+
if (!policy.managed) return withUnmanagedOperationPermission(options.environment, work);
|
|
255
|
+
const description = describeInvocation(invocation, options);
|
|
256
|
+
if (description.decision === "deny") denied();
|
|
257
|
+
const existing = activeAdmission(description, invocation.input, options);
|
|
258
|
+
if (existing !== undefined) {
|
|
259
|
+
if (options.plan !== undefined && existing.planDigest !== options.plan.digest) return changed();
|
|
260
|
+
await checkOperationPermission(invocation, options);
|
|
261
|
+
const result = await work();
|
|
262
|
+
await checkOperationPermission(invocation, options);
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
const checked = description.decision === "ask" ? checkedApproval(invocation, description, options.plan ?? null) : null;
|
|
266
|
+
// Capture the authorized values before the first await: readonly TypeScript input is not an immutable runtime object.
|
|
267
|
+
const inputHashes = [sha256(canonicalJson(invocation.input)), ...(options.plan?.plan.messagingComposite?.parts.map(part => sha256(canonicalJson(part.input))) ?? [])];
|
|
268
|
+
const planDigest = options.plan?.digest ?? null;
|
|
269
|
+
const stateHome = ghostgetStateHome(options.environment);
|
|
270
|
+
let lease: ApprovalLease | null = null;
|
|
271
|
+
if (description.decision === "ask") {
|
|
272
|
+
const target: ProviderTarget = { kind: "provider", adapterId: invocation.manifest.id, operationId: invocation.operationId,
|
|
273
|
+
authId: invocation.auth.kind === "public-web-session" ? null : invocation.auth.id,
|
|
274
|
+
input: options.plan === undefined ? inputForTarget(invocation.input) : null, planDigest: options.plan?.digest ?? null };
|
|
275
|
+
const { requestApproval } = await import("./control/approval-client");
|
|
276
|
+
if (checked === null) return changed();
|
|
277
|
+
lease = await requestApproval(target, checked.digest, { environment: options.environment, ...(options.signal === undefined ? {} : { signal: options.signal }) });
|
|
278
|
+
}
|
|
279
|
+
const admission: Admission = Object.freeze({ capability: description.digest, requestDigest: checked?.digest ?? description.digest, revision: description.revision,
|
|
280
|
+
inputs: new Set(inputHashes), planDigest, lease, stateHome });
|
|
281
|
+
try {
|
|
282
|
+
return await admissions.run([...(admissions.getStore() ?? []), admission], async () => {
|
|
283
|
+
const current = describeInvocation(invocation, options);
|
|
284
|
+
if (current.digest !== description.digest || current.revision !== description.revision) changed();
|
|
285
|
+
await checkOperationPermission(invocation, options);
|
|
286
|
+
const result = await work();
|
|
287
|
+
await checkOperationPermission(invocation, options);
|
|
288
|
+
return result;
|
|
289
|
+
});
|
|
290
|
+
} finally {
|
|
291
|
+
if (lease !== null) {
|
|
292
|
+
const { releaseApproval } = await import("./control/approval-client");
|
|
293
|
+
await releaseApproval(lease, { environment: options.environment });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Preserve synchronous native admission while preventing opt-in during an in-flight legacy request from disclosing its result. */
|
|
299
|
+
export async function withUnmanagedOperationPermission<T>(environment: PermissionEnvironment, work: () => Promise<T>): Promise<T> {
|
|
300
|
+
const result = await work();
|
|
301
|
+
if (readOperationPolicy(environment).managed) changed();
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function withOperationPermissions<T>(invocations: readonly PreparedInvocation[], options: Options, work: () => Promise<T>): Promise<T> {
|
|
306
|
+
if (invocations.length === 0) return work();
|
|
307
|
+
return withOperationPermission(invocations[0]!, options, () => withOperationPermissions(invocations.slice(1), options, work));
|
|
308
|
+
}
|
package/src/pinned-https.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type PinnedHttpsResponse = PinnedNetworkResponse;
|
|
|
15
15
|
export type PinnedHttpsDependencies = {
|
|
16
16
|
readonly resolveTarget: typeof resolveSafeNetworkTarget;
|
|
17
17
|
readonly request: NetworkTransport;
|
|
18
|
+
readonly beforeRequest?: () => void;
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export type PinnedHttpsFetch = (
|
|
@@ -103,14 +104,18 @@ export async function pinnedHttpsFetch(
|
|
|
103
104
|
throw new Error("authenticated request timeout is invalid");
|
|
104
105
|
}
|
|
105
106
|
const resolved = { ...defaultDependencies, ...dependencies };
|
|
107
|
+
init.signal.throwIfAborted();
|
|
106
108
|
const addresses = await resolved.resolveTarget(url, {
|
|
107
109
|
allowPrivateNetwork: false,
|
|
108
110
|
timeoutMs,
|
|
109
111
|
});
|
|
112
|
+
init.signal.throwIfAborted();
|
|
110
113
|
const address = addresses[0];
|
|
111
114
|
if (address === undefined) throw new Error("authenticated HTTPS origin did not resolve to a safe address");
|
|
112
115
|
const headers = new Headers(init.headers);
|
|
113
116
|
const body = requestBody(init.body);
|
|
117
|
+
resolved.beforeRequest?.();
|
|
118
|
+
init.signal.throwIfAborted();
|
|
114
119
|
const response = await resolved.request({
|
|
115
120
|
url,
|
|
116
121
|
address,
|
package/src/provider-http.ts
CHANGED
|
@@ -76,7 +76,7 @@ function canonicalTokenPath(path: string): string {
|
|
|
76
76
|
return current;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function readPrivateTokenFile(path: string): string {
|
|
79
|
+
function readPrivateTokenFile(path: string, expectedContent?: string): string {
|
|
80
80
|
if (!isAbsolute(path)) throw new Error("OAuth token file path must be absolute");
|
|
81
81
|
const canonical = canonicalTokenPath(path);
|
|
82
82
|
const descriptor = openSync(
|
|
@@ -112,6 +112,11 @@ function readPrivateTokenFile(path: string): string {
|
|
|
112
112
|
|| before.ctimeNs !== after.ctimeNs
|
|
113
113
|
|| before.mode !== after.mode
|
|
114
114
|
) throw new Error("OAuth token file changed while it was read");
|
|
115
|
+
// Bind write readback before decoding, including byte-order marks and
|
|
116
|
+
// formatting. Never derive cleanup authority from different stored bytes.
|
|
117
|
+
if (expectedContent !== undefined && !buffer.equals(Buffer.from(expectedContent, "utf8"))) {
|
|
118
|
+
throw new Error("OAuth credential file does not match its intended content");
|
|
119
|
+
}
|
|
115
120
|
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
116
121
|
} finally {
|
|
117
122
|
closeSync(descriptor);
|
|
@@ -190,11 +195,14 @@ function parseGoogleInstalledAppRefresh(value: unknown): GoogleInstalledAppRefre
|
|
|
190
195
|
}
|
|
191
196
|
|
|
192
197
|
/** Load and strictly bind a private token document without enforcing freshness. */
|
|
193
|
-
export function loadOAuthCredential(
|
|
198
|
+
export function loadOAuthCredential(
|
|
199
|
+
auth: OAuthTokenAuth,
|
|
200
|
+
options: Readonly<{ expectedContent?: string }> = {},
|
|
201
|
+
): LoadedOAuthCredential {
|
|
194
202
|
let content: string;
|
|
195
203
|
let parsed: unknown;
|
|
196
204
|
try {
|
|
197
|
-
content = readPrivateTokenFile(auth.path);
|
|
205
|
+
content = readPrivateTokenFile(auth.path, options.expectedContent);
|
|
198
206
|
parsed = JSON.parse(content) as unknown;
|
|
199
207
|
} catch (error) {
|
|
200
208
|
throw new Error(`could not load private ${auth.provider} OAuth token document`, { cause: error });
|
|
@@ -303,8 +303,9 @@ const identities = Object.freeze({
|
|
|
303
303
|
"linkedin-web": {
|
|
304
304
|
schemaVersion: 1,
|
|
305
305
|
pluginVersion: "1.6.0",
|
|
306
|
-
implementationSha256: "
|
|
306
|
+
implementationSha256: "95000a4d81f2fdbd222fa793b229704ba77ab9a23d02b26db5fa561bd5d6e491",
|
|
307
307
|
legacyCurrentReadImplementationSha256: [
|
|
308
|
+
"11b52ca3dd9cbafe4829eed9f27d59bdc1c312db943ea29b93cbab6417ac7025",
|
|
308
309
|
"e4614ab9c7733d7526b8157410e462f1bb48fdaa1c2319a6a4179c18b9e6839d",
|
|
309
310
|
"97194dc5a8ec3afc4f4a20152efe51dbdc2fa8a8dde0cd9484b54718c4767f45",
|
|
310
311
|
"64621493e09949fcb9973270140fec1d7398374cf48d3ef150a0c24b882fdeb8",
|
|
@@ -316,7 +317,6 @@ const identities = Object.freeze({
|
|
|
316
317
|
"dbda2aee2075a0a3726241fd24932113ae6c2d139ae1f7036ea827a335f7344c",
|
|
317
318
|
"9acb34f9ef5f59dd46b249766af236a3876a4975e0797e330f2975b3fd4ed643",
|
|
318
319
|
"951102310bd87f93ce0863c1a444371d62f412934cc805f82c9981f82edfe50e",
|
|
319
|
-
"4fd6c293984be688a9273acb09bccea64a75134daca745b322fa8d33dfe9f97c",
|
|
320
320
|
],
|
|
321
321
|
legacyReadImplementationSha256: {
|
|
322
322
|
test: "00a99426ec31182f8d37d5cceb819947a09e630b468bb6e3ed8827f5b0fa4628",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
type LiteralModuleImport = {
|
|
4
|
+
readonly kind: string;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const scanners = Object.freeze({
|
|
9
|
+
js: new Bun.Transpiler({ loader: "js" }),
|
|
10
|
+
ts: new Bun.Transpiler({ loader: "ts" }),
|
|
11
|
+
});
|
|
12
|
+
const MAX_MEMO_ENTRIES = 2_048;
|
|
13
|
+
const MAX_MEMO_TEXT_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const importsMemo = new Map<string, {
|
|
15
|
+
readonly imports: readonly LiteralModuleImport[];
|
|
16
|
+
readonly textBytes: number;
|
|
17
|
+
}>();
|
|
18
|
+
let memoTextBytes = 0;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pure syntax only: callers still read current bytes, enforce their bounds,
|
|
22
|
+
* resolve every edge from its current importer, and revalidate the filesystem.
|
|
23
|
+
* Keep only immutable scanner output; never retain source text or an AST.
|
|
24
|
+
*/
|
|
25
|
+
export function scanProviderPluginValueImports(
|
|
26
|
+
source: string,
|
|
27
|
+
loader: "js" | "ts",
|
|
28
|
+
): readonly LiteralModuleImport[] {
|
|
29
|
+
const key = `${loader}\0${createHash("sha256").update(source).digest("hex")}`;
|
|
30
|
+
const cached = importsMemo.get(key);
|
|
31
|
+
if (cached !== undefined) return cached.imports;
|
|
32
|
+
const imports = Object.freeze(scanners[loader].scanImports(source).map(
|
|
33
|
+
({ kind, path }) => Object.freeze({ kind, path }),
|
|
34
|
+
));
|
|
35
|
+
const textBytes = key.length + imports.reduce(
|
|
36
|
+
(total, entry) => total + Buffer.byteLength(entry.kind) + Buffer.byteLength(entry.path),
|
|
37
|
+
0,
|
|
38
|
+
);
|
|
39
|
+
// Count entries as well as returned text so empty modules stay bounded too.
|
|
40
|
+
// Oversized foreign input is still rejected by the caller's own import bound.
|
|
41
|
+
if (imports.length > 4_096 || textBytes > MAX_MEMO_TEXT_BYTES) return imports;
|
|
42
|
+
while (importsMemo.size >= MAX_MEMO_ENTRIES
|
|
43
|
+
|| memoTextBytes + textBytes > MAX_MEMO_TEXT_BYTES) {
|
|
44
|
+
const oldest = importsMemo.entries().next().value;
|
|
45
|
+
if (oldest === undefined) break;
|
|
46
|
+
importsMemo.delete(oldest[0]);
|
|
47
|
+
memoTextBytes -= oldest[1].textBytes;
|
|
48
|
+
}
|
|
49
|
+
importsMemo.set(key, { imports, textBytes });
|
|
50
|
+
memoTextBytes += textBytes;
|
|
51
|
+
return imports;
|
|
52
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { extname } from "node:path";
|
|
2
3
|
import ts from "typescript";
|
|
3
4
|
|
|
@@ -8,6 +9,12 @@ const opaqueModuleLoaderNames = new Set([
|
|
|
8
9
|
"getBuiltinModule",
|
|
9
10
|
]);
|
|
10
11
|
|
|
12
|
+
// Only a boolean is retained, keyed by the freshly supplied source and every
|
|
13
|
+
// parser input. Filename is included because the source-local binding proof
|
|
14
|
+
// creates a TypeScript program. No AST, path resolution, or authority is cached.
|
|
15
|
+
const nonLiteralLoadMemo = new Map<string, boolean>();
|
|
16
|
+
const MAX_NON_LITERAL_LOAD_MEMO_ENTRIES = 4_096;
|
|
17
|
+
|
|
11
18
|
function hasOneLiteralModuleArgument(node: ts.CallExpression): boolean {
|
|
12
19
|
const [argument] = node.arguments;
|
|
13
20
|
return node.arguments.length === 1
|
|
@@ -244,12 +251,20 @@ function localEvalCallbackProof(sourceFile: ts.SourceFile): (node: ts.Node) => b
|
|
|
244
251
|
* rather than attempting to sandbox deliberately obfuscated JavaScript.
|
|
245
252
|
*/
|
|
246
253
|
export function hasNonLiteralModuleLoad(source: string, path: string): boolean {
|
|
254
|
+
const scriptKind = providerPluginScriptKind(path);
|
|
255
|
+
const key = createHash("sha256")
|
|
256
|
+
.update(JSON.stringify([path, scriptKind]))
|
|
257
|
+
.update("\0")
|
|
258
|
+
.update(source)
|
|
259
|
+
.digest("hex");
|
|
260
|
+
const cached = nonLiteralLoadMemo.get(key);
|
|
261
|
+
if (cached !== undefined) return cached;
|
|
247
262
|
const sourceFile = ts.createSourceFile(
|
|
248
263
|
path,
|
|
249
264
|
source,
|
|
250
265
|
ts.ScriptTarget.ESNext,
|
|
251
266
|
false,
|
|
252
|
-
|
|
267
|
+
scriptKind,
|
|
253
268
|
);
|
|
254
269
|
let callbackProof: ((node: ts.Node) => boolean) | undefined;
|
|
255
270
|
const isLocalCallback = (node: ts.Node): boolean => {
|
|
@@ -332,5 +347,10 @@ export function hasNonLiteralModuleLoad(source: string, path: string): boolean {
|
|
|
332
347
|
node.forEachChild((child) => visit(child, node));
|
|
333
348
|
};
|
|
334
349
|
visit(sourceFile);
|
|
350
|
+
if (nonLiteralLoadMemo.size >= MAX_NON_LITERAL_LOAD_MEMO_ENTRIES) {
|
|
351
|
+
const oldest = nonLiteralLoadMemo.keys().next().value;
|
|
352
|
+
if (oldest !== undefined) nonLiteralLoadMemo.delete(oldest);
|
|
353
|
+
}
|
|
354
|
+
nonLiteralLoadMemo.set(key, found);
|
|
335
355
|
return found;
|
|
336
356
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
sep,
|
|
25
25
|
} from "node:path";
|
|
26
26
|
import ts from "typescript";
|
|
27
|
+
import { scanProviderPluginValueImports } from "./provider-plugin-import-analysis";
|
|
27
28
|
import {
|
|
28
29
|
hasNonLiteralModuleLoad,
|
|
29
30
|
providerPluginScriptKind,
|
|
@@ -540,10 +541,6 @@ function readDependencySourceFromDisk(path: string): Buffer {
|
|
|
540
541
|
);
|
|
541
542
|
}
|
|
542
543
|
|
|
543
|
-
const providerPluginImportScanners = Object.freeze({
|
|
544
|
-
js: new Bun.Transpiler({ loader: "js" }),
|
|
545
|
-
ts: new Bun.Transpiler({ loader: "ts" }),
|
|
546
|
-
});
|
|
547
544
|
const providerPluginModuleExtensions = new Set([
|
|
548
545
|
".cjs",
|
|
549
546
|
".cts",
|
|
@@ -698,11 +695,10 @@ function valueImports(source: string, path: string): readonly {
|
|
|
698
695
|
`provider plugin implementation module ${path} uses configuration-dependent JSX or TSX; publish deterministic JavaScript or TypeScript instead`,
|
|
699
696
|
);
|
|
700
697
|
}
|
|
701
|
-
const
|
|
698
|
+
const loader =
|
|
702
699
|
extension === ".ts" || extension === ".mts" || extension === ".cts"
|
|
703
|
-
?
|
|
704
|
-
|
|
705
|
-
return scanner.scanImports(moduleSource);
|
|
700
|
+
? "ts" : "js";
|
|
701
|
+
return scanProviderPluginValueImports(moduleSource, loader);
|
|
706
702
|
}
|
|
707
703
|
|
|
708
704
|
|