@remembrance-ai/opencode-plugin 0.1.47 → 0.1.49

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/README.md CHANGED
@@ -49,9 +49,10 @@ chmod 600 ~/.config/remembrance/config.json
49
49
  local MCP server both read `~/.config/remembrance/config.json`, an unset
50
50
  `REMEMBRANCE_API_KEY` does **not** prove this install is anonymous.
51
51
 
52
- After restarting, call MCP `get_connection_status`. It should report
53
- `local_stdio_mcp`, the resolved credential source, and the expected organization
54
- scope. Never infer scope from `REMEMBRANCE_API_KEY` alone, from an environment
52
+ After restarting, run MCP `run_connection_doctor` and require
53
+ `safe_to_query: true`. It verifies the active connection and gives one exact
54
+ next step if attention is required. Use `get_connection_status` only for lower-
55
+ level fields. Never infer scope from `REMEMBRANCE_API_KEY` alone, from an environment
55
56
  variable, or from an anonymous REST/browser probe — the diagnostic verifies the
56
57
  process that will actually serve opencode's tools, without exposing the key.
57
58
 
@@ -66,6 +67,11 @@ printf '{"apiKey":"YOUR_ORG_KEY","apiUrl":"https://dev.remembrance.dev"}\n' \
66
67
  > ~/.config/remembrance/config.json
67
68
  ```
68
69
 
70
+ This paired file binds the key to that destination. If environment variables
71
+ are used instead, set `REMEMBRANCE_API_KEY_ORIGIN` equal to the exact custom
72
+ `REMEMBRANCE_API_URL`. Remote registries require HTTPS; a trusted private or
73
+ link-local self-host also requires `REMEMBRANCE_ALLOW_PRIVATE_REGISTRY=true`.
74
+
69
75
  ## What the plugin does
70
76
 
71
77
  | Event | Behavior |
package/opencode.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "command": [
7
7
  "npx",
8
8
  "-y",
9
- "@remembrance-ai/mcp-server@0.1.47"
9
+ "@remembrance-ai/mcp-server@0.1.49"
10
10
  ],
11
11
  "enabled": true,
12
12
  "environment": {
@@ -15,6 +15,6 @@
15
15
  }
16
16
  },
17
17
  "plugin": [
18
- "@remembrance-ai/opencode-plugin@0.1.47"
18
+ "@remembrance-ai/opencode-plugin@0.1.49"
19
19
  ]
20
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remembrance-ai/opencode-plugin",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
4
4
  "license": "Apache-2.0",
5
5
  "description": "opencode plugin for Remembrance session hooks, tool observation, and contribution nudges.",
6
6
  "type": "module",
@@ -17,15 +17,23 @@
17
17
 
18
18
  import { createHash, randomBytes } from "node:crypto";
19
19
  import {
20
+ chmodSync,
21
+ closeSync,
22
+ constants,
20
23
  existsSync,
24
+ fstatSync,
25
+ lstatSync,
21
26
  mkdirSync,
27
+ openSync,
22
28
  readFileSync,
29
+ readSync,
23
30
  readdirSync,
24
31
  renameSync,
25
32
  rmSync,
26
33
  statSync,
27
34
  writeFileSync,
28
35
  } from "node:fs";
36
+ import { isIP } from "node:net";
29
37
  import { homedir, tmpdir } from "node:os";
30
38
  import { dirname, join } from "node:path";
31
39
  import process from "node:process";
@@ -43,7 +51,8 @@ const VALUE_EPISODE_MARKER_LIMIT = 20;
43
51
  const VALUE_EPISODE_MARKER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
44
52
  const DIRECT_SELECTION_MARKER_LIMIT = 20;
45
53
  const DIRECT_SELECTION_MARKER_TTL_MS = 24 * 60 * 60 * 1000;
46
- const PLUGIN_HEALTH_DIR = "remembrance-plugin-health";
54
+ const MAX_LOCAL_CONFIG_BYTES = 64 * 1024;
55
+ const MAX_LOCAL_HEALTH_MARKER_BYTES = 16 * 1024;
47
56
  const PLUGIN_HEALTH_COMPONENTS = new Set([
48
57
  "session_start",
49
58
  "prompt_hook",
@@ -2279,9 +2288,20 @@ export function contributeDisabled(value) {
2279
2288
  }
2280
2289
 
2281
2290
  function pluginHealthDir(env = process.env) {
2282
- return env?.REMEMBRANCE_PLUGIN_HEALTH_DIR
2283
- ? String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR)
2284
- : join(tmpdir(), PLUGIN_HEALTH_DIR);
2291
+ if (env?.REMEMBRANCE_PLUGIN_HEALTH_DIR) {
2292
+ return String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR);
2293
+ }
2294
+ // Hook adapters are exercised directly by several Vitest suites. Keep those
2295
+ // observations out of the real user health directory even when a test passes
2296
+ // a deliberately minimal env object that omits Vitest's process variables.
2297
+ if (process.env.VITEST) {
2298
+ return join(
2299
+ tmpdir(),
2300
+ "remembrance-plugin-health-tests",
2301
+ String(process.pid),
2302
+ );
2303
+ }
2304
+ return join(homedir(), ".cache", "remembrance", "plugin-health");
2285
2305
  }
2286
2306
 
2287
2307
  function normalizedPluginHealthSurface(value) {
@@ -2317,7 +2337,9 @@ export function readPluginLifecycleHealth(
2317
2337
  const path = pluginHealthSessionPath(surface, sessionId, env);
2318
2338
  if (!path) return null;
2319
2339
  try {
2320
- const parsed = JSON.parse(readFileSync(path, "utf8"));
2340
+ const parsed = JSON.parse(
2341
+ readSecureHookFile(path, MAX_LOCAL_HEALTH_MARKER_BYTES),
2342
+ );
2321
2343
  return parsed && typeof parsed === "object" && !Array.isArray(parsed)
2322
2344
  ? parsed
2323
2345
  : null;
@@ -2329,7 +2351,7 @@ export function readPluginLifecycleHealth(
2329
2351
  // Record that a native lifecycle component actually executed. The marker is
2330
2352
  // intentionally local and content-free: it contains only surface/version,
2331
2353
  // component timestamps, and the credential source category. Local MCP reads
2332
- // it through get_connection_status so partial activation cannot look healthy.
2354
+ // it through run_connection_doctor so partial activation cannot look healthy.
2333
2355
  export function recordPluginLifecycleHealth(
2334
2356
  {
2335
2357
  surface,
@@ -2351,6 +2373,28 @@ export function recordPluginLifecycleHealth(
2351
2373
  ) {
2352
2374
  return false;
2353
2375
  }
2376
+ // Codex currently gives SessionStart a thread identifier while prompt/tool/
2377
+ // completion hooks use turn identifiers. Preserve the specific startup
2378
+ // marker for diagnostics, but also publish a generic startup observation so
2379
+ // the first turn-specific marker can inherit it. Other hosts with stable
2380
+ // session ids remain correct and gain the same conservative fallback.
2381
+ if (
2382
+ normalizedComponent === "session_start" &&
2383
+ sessionId &&
2384
+ sessionId !== "unknown"
2385
+ ) {
2386
+ recordPluginLifecycleHealth(
2387
+ {
2388
+ surface: normalizedSurface,
2389
+ component: normalizedComponent,
2390
+ pluginVersion,
2391
+ hostVersion,
2392
+ credentialSource,
2393
+ sessionId: null,
2394
+ },
2395
+ env,
2396
+ );
2397
+ }
2354
2398
  const path = pluginHealthSessionPath(normalizedSurface, sessionId, env);
2355
2399
  if (!path) return false;
2356
2400
  const now = new Date().toISOString();
@@ -2390,6 +2434,11 @@ export function recordPluginLifecycleHealth(
2390
2434
  // healthy for as long as the old marker remains fresh.
2391
2435
  const currentComponents =
2392
2436
  normalizedComponent === "session_start" ? {} : existingComponents;
2437
+ const apiConfiguration = resolveApiConfiguration(env);
2438
+ const apiDestinationFingerprint = createHash("sha256")
2439
+ .update(apiConfiguration.apiUrl.replace(/\/+$/, ""), "utf8")
2440
+ .digest("hex")
2441
+ .slice(0, 16);
2393
2442
  const payload = {
2394
2443
  schema_version: 1,
2395
2444
  surface: normalizedSurface,
@@ -2414,6 +2463,8 @@ export function recordPluginLifecycleHealth(
2414
2463
  )
2415
2464
  ? existing.credential_source
2416
2465
  : "none",
2466
+ api_destination_source: apiConfiguration.source,
2467
+ api_destination_fingerprint: apiDestinationFingerprint,
2417
2468
  components: {
2418
2469
  ...currentComponents,
2419
2470
  [normalizedComponent]: now,
@@ -2422,9 +2473,10 @@ export function recordPluginLifecycleHealth(
2422
2473
  };
2423
2474
  const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
2424
2475
  try {
2425
- mkdirSync(dirname(path), { recursive: true });
2476
+ ensureSecureHookDirectory(dirname(path));
2426
2477
  writeFileSync(temporaryPath, `${JSON.stringify(payload)}\n`, {
2427
2478
  mode: 0o600,
2479
+ flag: "wx",
2428
2480
  });
2429
2481
  renameSync(temporaryPath, path);
2430
2482
  prunePluginHealthSessionMarkers(normalizedSurface, path, env);
@@ -2481,7 +2533,9 @@ export function remembranceConfigPath(env = process.env) {
2481
2533
 
2482
2534
  export function readRemembranceConfig(env = process.env) {
2483
2535
  try {
2484
- const parsed = JSON.parse(readFileSync(remembranceConfigPath(env), "utf8"));
2536
+ const parsed = JSON.parse(
2537
+ readSecureHookFile(remembranceConfigPath(env), MAX_LOCAL_CONFIG_BYTES),
2538
+ );
2485
2539
  return parsed && typeof parsed === "object" && !Array.isArray(parsed)
2486
2540
  ? parsed
2487
2541
  : {};
@@ -2497,32 +2551,59 @@ export function resolveApiCredential(env = process.env) {
2497
2551
  }
2498
2552
  const environmentKey = String(env.REMEMBRANCE_API_KEY ?? "").trim();
2499
2553
  if (environmentKey) {
2500
- return {
2501
- apiKey: environmentKey,
2502
- source: "environment",
2503
- };
2554
+ const explicitBinding = String(
2555
+ env.REMEMBRANCE_API_KEY_ORIGIN ?? "",
2556
+ ).trim();
2557
+ const binding = explicitBinding
2558
+ ? normalizeApiUrl(explicitBinding, env)
2559
+ : { apiUrl: DEFAULT_API_URL, issue: null };
2560
+ return credentialForApiDestination(
2561
+ binding.apiUrl && !binding.issue
2562
+ ? {
2563
+ apiKey: environmentKey,
2564
+ source: "environment",
2565
+ boundApiUrl: binding.apiUrl,
2566
+ }
2567
+ : unusableDestinationCredential(),
2568
+ apiConfiguration,
2569
+ );
2504
2570
  }
2505
2571
  const path = remembranceConfigPath(env);
2506
2572
  if (!existsSync(path)) {
2507
2573
  return { apiKey: "", source: "none" };
2508
2574
  }
2509
2575
  try {
2510
- const parsed = JSON.parse(readFileSync(path, "utf8"));
2576
+ const parsed = JSON.parse(
2577
+ readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES),
2578
+ );
2511
2579
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2512
- return { apiKey: "", source: "unusable_shared_config" };
2580
+ return unusableSharedConfigCredential();
2513
2581
  }
2514
2582
  if (
2515
2583
  Object.prototype.hasOwnProperty.call(parsed, "apiKey") &&
2516
2584
  (typeof parsed.apiKey !== "string" || !parsed.apiKey.trim())
2517
2585
  ) {
2518
- return { apiKey: "", source: "unusable_shared_config" };
2586
+ return unusableSharedConfigCredential();
2519
2587
  }
2520
2588
  const fromFile = parsed.apiKey;
2521
- return typeof fromFile === "string" && fromFile.trim()
2522
- ? { apiKey: fromFile.trim(), source: "shared_config" }
2523
- : { apiKey: "", source: "none" };
2589
+ if (typeof fromFile !== "string" || !fromFile.trim()) {
2590
+ return { apiKey: "", source: "none" };
2591
+ }
2592
+ const binding = Object.prototype.hasOwnProperty.call(parsed, "apiUrl")
2593
+ ? normalizeApiUrl(parsed.apiUrl, env)
2594
+ : { apiUrl: DEFAULT_API_URL, issue: null };
2595
+ return credentialForApiDestination(
2596
+ binding.apiUrl && !binding.issue
2597
+ ? {
2598
+ apiKey: fromFile.trim(),
2599
+ source: "shared_config",
2600
+ boundApiUrl: binding.apiUrl,
2601
+ }
2602
+ : unusableSharedConfigCredential(),
2603
+ apiConfiguration,
2604
+ );
2524
2605
  } catch {
2525
- return { apiKey: "", source: "unusable_shared_config" };
2606
+ return unusableSharedConfigCredential();
2526
2607
  }
2527
2608
  }
2528
2609
 
@@ -2538,8 +2619,10 @@ export function sharedConfigCredentialNotice(env = process.env) {
2538
2619
  return (
2539
2620
  "Remembrance setup needs attention: REMEMBRANCE_API_URL is invalid. " +
2540
2621
  "Remote Remembrance calls are paused locally instead of changing " +
2541
- "destinations. Set it to an absolute HTTP(S) registry URL or " +
2542
- "intentionally remove it, then call get_connection_status."
2622
+ "destinations. Set it to an absolute HTTPS registry URL (HTTP is allowed " +
2623
+ "only for loopback development) without credentials, query parameters, " +
2624
+ "or fragments, or " +
2625
+ "intentionally remove it, then run run_connection_doctor."
2543
2626
  );
2544
2627
  }
2545
2628
  if (source === "unusable_shared_config") {
@@ -2547,7 +2630,16 @@ export function sharedConfigCredentialNotice(env = process.env) {
2547
2630
  "Remembrance setup needs attention: the shared config file exists but " +
2548
2631
  "is unreadable or invalid. Remote Remembrance calls are paused locally " +
2549
2632
  "instead of falling back to anonymous scope. Fix or intentionally remove " +
2550
- "~/.config/remembrance/config.json, then call get_connection_status."
2633
+ "~/.config/remembrance/config.json, then run run_connection_doctor."
2634
+ );
2635
+ }
2636
+ if (source === "unusable_destination_binding") {
2637
+ return (
2638
+ "Remembrance setup needs attention: the API key is not bound to the " +
2639
+ "configured registry destination. For a custom registry, store apiKey " +
2640
+ "and apiUrl together in the shared config, or set " +
2641
+ "REMEMBRANCE_API_KEY_ORIGIN to the exact REMEMBRANCE_API_URL value. " +
2642
+ "Remote calls are paused so the key cannot be forwarded elsewhere."
2551
2643
  );
2552
2644
  }
2553
2645
  if (source !== "shared_config") {
@@ -2558,24 +2650,30 @@ export function sharedConfigCredentialNotice(env = process.env) {
2558
2650
  `the shared Remembrance config file (normally ` +
2559
2651
  `~/.config/remembrance/config.json). REMEMBRANCE_API_KEY may be unset; do not ` +
2560
2652
  `use an anonymous REST/browser probe to infer this hook's scope. Call ` +
2561
- `get_connection_status for the MCP transport you will use.`
2653
+ `run_connection_doctor for the MCP transport you will use.`
2562
2654
  );
2563
2655
  }
2564
2656
 
2565
2657
  export function resolveApiConfiguration(env = process.env) {
2566
2658
  const environmentUrl = String(env.REMEMBRANCE_API_URL ?? "").trim();
2567
2659
  if (environmentUrl) {
2568
- const normalized = normalizeApiUrl(environmentUrl);
2569
- return normalized
2570
- ? { apiUrl: normalized, source: "environment" }
2571
- : { apiUrl: DEFAULT_API_URL, source: "unusable_environment" };
2660
+ const normalized = normalizeApiUrl(environmentUrl, env);
2661
+ return normalized.apiUrl && !normalized.issue
2662
+ ? { apiUrl: normalized.apiUrl, source: "environment" }
2663
+ : {
2664
+ apiUrl: DEFAULT_API_URL,
2665
+ source: "unusable_environment",
2666
+ issue: normalized.issue ?? "invalid_url",
2667
+ };
2572
2668
  }
2573
2669
  const path = remembranceConfigPath(env);
2574
2670
  if (!existsSync(path)) {
2575
2671
  return { apiUrl: DEFAULT_API_URL, source: "default" };
2576
2672
  }
2577
2673
  try {
2578
- const parsed = JSON.parse(readFileSync(path, "utf8"));
2674
+ const parsed = JSON.parse(
2675
+ readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES),
2676
+ );
2579
2677
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2580
2678
  return {
2581
2679
  apiUrl: DEFAULT_API_URL,
@@ -2585,10 +2683,14 @@ export function resolveApiConfiguration(env = process.env) {
2585
2683
  if (!Object.prototype.hasOwnProperty.call(parsed, "apiUrl")) {
2586
2684
  return { apiUrl: DEFAULT_API_URL, source: "default" };
2587
2685
  }
2588
- const normalized = normalizeApiUrl(parsed.apiUrl);
2589
- return normalized
2590
- ? { apiUrl: normalized, source: "shared_config" }
2591
- : { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
2686
+ const normalized = normalizeApiUrl(parsed.apiUrl, env);
2687
+ return normalized.apiUrl && !normalized.issue
2688
+ ? { apiUrl: normalized.apiUrl, source: "shared_config" }
2689
+ : {
2690
+ apiUrl: DEFAULT_API_URL,
2691
+ source: "unusable_shared_config",
2692
+ issue: normalized.issue ?? "invalid_url",
2693
+ };
2592
2694
  } catch {
2593
2695
  return { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
2594
2696
  }
@@ -2598,8 +2700,10 @@ function apiUrl(env) {
2598
2700
  return resolveApiConfiguration(env).apiUrl;
2599
2701
  }
2600
2702
 
2601
- function normalizeApiUrl(value) {
2602
- if (typeof value !== "string" || !value.trim()) return null;
2703
+ function normalizeApiUrl(value, env = process.env) {
2704
+ if (typeof value !== "string" || !value.trim()) {
2705
+ return { apiUrl: null, issue: "invalid_url" };
2706
+ }
2603
2707
  const candidate = value.trim();
2604
2708
  try {
2605
2709
  const parsed = new URL(candidate);
@@ -2610,17 +2714,193 @@ function normalizeApiUrl(value) {
2610
2714
  parsed.search ||
2611
2715
  parsed.hash
2612
2716
  ) {
2613
- return null;
2717
+ return { apiUrl: null, issue: "invalid_url" };
2718
+ }
2719
+ const loopback = isLoopbackHostname(parsed.hostname);
2720
+ const privateDestination = isPrivateDestinationHostname(parsed.hostname);
2721
+ if (parsed.protocol === "http:" && !loopback) {
2722
+ return { apiUrl: null, issue: "insecure_remote_http" };
2723
+ }
2724
+ if (
2725
+ !loopback &&
2726
+ privateDestination &&
2727
+ env.REMEMBRANCE_ALLOW_PRIVATE_REGISTRY !== "true"
2728
+ ) {
2729
+ return {
2730
+ apiUrl: null,
2731
+ issue: "private_destination_requires_opt_in",
2732
+ };
2614
2733
  }
2615
- return candidate.replace(/\/+$/, "");
2734
+ return { apiUrl: candidate.replace(/\/+$/, ""), issue: null };
2616
2735
  } catch {
2617
- return null;
2736
+ return { apiUrl: null, issue: "invalid_url" };
2737
+ }
2738
+ }
2739
+
2740
+ function credentialForApiDestination(credential, configuration) {
2741
+ if (!credential.apiKey || isUnusableConfigurationSource(configuration.source)) {
2742
+ return credential.apiKey
2743
+ ? unusableDestinationCredential()
2744
+ : { apiKey: "", source: credential.source };
2618
2745
  }
2746
+ return credential.boundApiUrl === configuration.apiUrl
2747
+ ? { apiKey: credential.apiKey, source: credential.source }
2748
+ : unusableDestinationCredential();
2749
+ }
2750
+
2751
+ function unusableSharedConfigCredential() {
2752
+ return {
2753
+ apiKey: "",
2754
+ source: "unusable_shared_config",
2755
+ };
2756
+ }
2757
+
2758
+ function unusableDestinationCredential() {
2759
+ return {
2760
+ apiKey: "",
2761
+ source: "unusable_destination_binding",
2762
+ };
2763
+ }
2764
+
2765
+ function isLoopbackHostname(hostname) {
2766
+ const normalized = normalizeHostname(hostname);
2767
+ if (normalized === "localhost" || normalized.endsWith(".localhost")) {
2768
+ return true;
2769
+ }
2770
+ if (normalized === "::1") return true;
2771
+ if (isIP(normalized) === 4) {
2772
+ return Number(normalized.split(".")[0]) === 127;
2773
+ }
2774
+ return false;
2775
+ }
2776
+
2777
+ function isPrivateDestinationHostname(hostname) {
2778
+ const normalized = normalizeHostname(hostname);
2779
+ if (
2780
+ normalized.endsWith(".local") ||
2781
+ normalized.endsWith(".internal") ||
2782
+ normalized === "localhost"
2783
+ ) {
2784
+ return true;
2785
+ }
2786
+ const version = isIP(normalized);
2787
+ if (version === 4) {
2788
+ const [a, b] = normalized.split(".").map(Number);
2789
+ return (
2790
+ a === 0 ||
2791
+ a === 10 ||
2792
+ a === 127 ||
2793
+ (a === 100 && b >= 64 && b <= 127) ||
2794
+ (a === 169 && b === 254) ||
2795
+ (a === 172 && b >= 16 && b <= 31) ||
2796
+ (a === 192 && b === 168) ||
2797
+ (a === 198 && (b === 18 || b === 19)) ||
2798
+ a >= 224
2799
+ );
2800
+ }
2801
+ if (version === 6) {
2802
+ return (
2803
+ normalized === "::" ||
2804
+ normalized === "::1" ||
2805
+ normalized.startsWith("fc") ||
2806
+ normalized.startsWith("fd") ||
2807
+ /^fe[89ab]/.test(normalized)
2808
+ );
2809
+ }
2810
+ return false;
2811
+ }
2812
+
2813
+ function normalizeHostname(hostname) {
2814
+ return String(hostname).replace(/^\[|\]$/g, "").toLowerCase();
2815
+ }
2816
+
2817
+ function readSecureHookFile(path, maxBytes) {
2818
+ const parent = lstatSync(dirname(path));
2819
+ assertSecureHookDirectory(parent);
2820
+ const before = lstatSync(path);
2821
+ assertSecureHookFile(before, maxBytes);
2822
+ const descriptor = openSync(
2823
+ path,
2824
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
2825
+ );
2826
+ try {
2827
+ const opened = fstatSync(descriptor);
2828
+ assertSecureHookFile(opened, maxBytes);
2829
+ if (before.dev !== opened.dev || before.ino !== opened.ino) {
2830
+ throw new Error("Remembrance local state changed while opening.");
2831
+ }
2832
+ return readBoundedHookDescriptor(descriptor, maxBytes);
2833
+ } finally {
2834
+ closeSync(descriptor);
2835
+ }
2836
+ }
2837
+
2838
+ function ensureSecureHookDirectory(path) {
2839
+ mkdirSync(path, { recursive: true, mode: 0o700 });
2840
+ const metadata = lstatSync(path);
2841
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
2842
+ throw new Error("Remembrance health state is not a regular directory.");
2843
+ }
2844
+ assertCurrentHookUser(metadata.uid);
2845
+ if (isPosixHookRuntime() && (metadata.mode & 0o077) !== 0) {
2846
+ chmodSync(path, 0o700);
2847
+ }
2848
+ }
2849
+
2850
+ function assertSecureHookDirectory(metadata) {
2851
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
2852
+ throw new Error("Remembrance local state parent is unsafe.");
2853
+ }
2854
+ assertCurrentHookUser(metadata.uid);
2855
+ if (isPosixHookRuntime() && (metadata.mode & 0o022) !== 0) {
2856
+ throw new Error("Remembrance local state parent is writable by others.");
2857
+ }
2858
+ }
2859
+
2860
+ function assertSecureHookFile(metadata, maxBytes) {
2861
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
2862
+ throw new Error("Remembrance local state is not a regular file.");
2863
+ }
2864
+ assertCurrentHookUser(metadata.uid);
2865
+ if (metadata.size > maxBytes) {
2866
+ throw new Error("Remembrance local state exceeds its size limit.");
2867
+ }
2868
+ if (isPosixHookRuntime() && (metadata.mode & 0o077) !== 0) {
2869
+ throw new Error("Remembrance local state permissions are not private.");
2870
+ }
2871
+ }
2872
+
2873
+ function assertCurrentHookUser(uid) {
2874
+ if (isPosixHookRuntime() && uid !== process.getuid()) {
2875
+ throw new Error("Remembrance local state is owned by another user.");
2876
+ }
2877
+ }
2878
+
2879
+ function readBoundedHookDescriptor(descriptor, maxBytes) {
2880
+ const chunks = [];
2881
+ let total = 0;
2882
+ while (total <= maxBytes) {
2883
+ const chunk = Buffer.allocUnsafe(Math.min(8 * 1024, maxBytes + 1 - total));
2884
+ const bytesRead = readSync(descriptor, chunk, 0, chunk.length, null);
2885
+ if (bytesRead === 0) break;
2886
+ chunks.push(chunk.subarray(0, bytesRead));
2887
+ total += bytesRead;
2888
+ }
2889
+ if (total > maxBytes) {
2890
+ throw new Error("Remembrance local state exceeds its size limit.");
2891
+ }
2892
+ return Buffer.concat(chunks, total).toString("utf8");
2893
+ }
2894
+
2895
+ function isPosixHookRuntime() {
2896
+ return typeof process.getuid === "function";
2619
2897
  }
2620
2898
 
2621
2899
  function isUnusableConfigurationSource(source) {
2622
2900
  return (
2623
- source === "unusable_environment" || source === "unusable_shared_config"
2901
+ source === "unusable_environment" ||
2902
+ source === "unusable_shared_config" ||
2903
+ source === "unusable_destination_binding"
2624
2904
  );
2625
2905
  }
2626
2906