@trops/dash-core 0.1.514 → 0.1.515

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.
@@ -1934,12 +1934,24 @@ function sanitizePerms(perms) {
1934
1934
  ? raw.writePaths.filter((p) => typeof p === "string")
1935
1935
  : [],
1936
1936
  };
1937
+ // Slice 4: per-action grant scoping. Persist `actions[]` only
1938
+ // when explicitly provided; the gate's legacy migration treats
1939
+ // its absence as "any action allowed" so pre-slice grants
1940
+ // continue to work.
1941
+ if (Array.isArray(raw.actions)) {
1942
+ domains.fs.actions = raw.actions.filter((a) => typeof a === "string");
1943
+ }
1937
1944
  } else if (name === "network") {
1938
1945
  domains.network = {
1939
1946
  hosts: Array.isArray(raw.hosts)
1940
1947
  ? raw.hosts.filter((h) => typeof h === "string")
1941
1948
  : [],
1942
1949
  };
1950
+ if (Array.isArray(raw.actions)) {
1951
+ domains.network.actions = raw.actions.filter(
1952
+ (a) => typeof a === "string",
1953
+ );
1954
+ }
1943
1955
  }
1944
1956
  // Future domains plug in here. Unknown domain names are dropped.
1945
1957
  }
@@ -2426,12 +2438,6 @@ function isFsWriteAction(action) {
2426
2438
  return WRITE_ACTIONS.has(action);
2427
2439
  }
2428
2440
 
2429
- function _isNoGrantDenial$1(reason) {
2430
- return (
2431
- typeof reason === "string" && /no fs permissions granted/i.test(reason)
2432
- );
2433
- }
2434
-
2435
2441
  function _filenameMatches(filename, allowedList) {
2436
2442
  if (!Array.isArray(allowedList) || allowedList.length === 0) return false;
2437
2443
  if (allowedList.includes("*")) return true;
@@ -2483,6 +2489,25 @@ function gateFsCall$1({ widgetId, token, action, args }) {
2483
2489
  };
2484
2490
  }
2485
2491
 
2492
+ // Slice 4: per-action allowlist. When `actions[]` is present and
2493
+ // non-empty, only listed actions are allowed (path scope still
2494
+ // applies). When absent / empty, fall through to legacy behavior
2495
+ // (any read/write-class action allowed against the path scope) so
2496
+ // pre-slice grants keep working — Option A migration.
2497
+ if (Array.isArray(fsPerms.actions) && fsPerms.actions.length > 0) {
2498
+ if (!fsPerms.actions.includes(action)) {
2499
+ return {
2500
+ allow: false,
2501
+ reason:
2502
+ "fs gate: action '" +
2503
+ action +
2504
+ "' not in actions allowlist for widget '" +
2505
+ widgetId +
2506
+ "'",
2507
+ };
2508
+ }
2509
+ }
2510
+
2486
2511
  const isWrite = isFsWriteAction(action);
2487
2512
 
2488
2513
  if (isWrite) {
@@ -2543,7 +2568,7 @@ function _mergeFsGrant(current, addition) {
2543
2568
  const additionFs = addition?.domains?.fs;
2544
2569
  if (additionFs) {
2545
2570
  const existingFs = out.domains.fs || { readPaths: [], writePaths: [] };
2546
- out.domains.fs = {
2571
+ const merged = {
2547
2572
  readPaths: [
2548
2573
  ...new Set([
2549
2574
  ...(existingFs.readPaths || []),
@@ -2559,30 +2584,80 @@ function _mergeFsGrant(current, addition) {
2559
2584
  ]),
2560
2585
  ],
2561
2586
  };
2587
+ // Slice 4: union `actions[]`. Only emit the field when at least
2588
+ // one side declared it — preserves Option A migration (a legacy
2589
+ // grant being extended without an action allowlist on the
2590
+ // addition still has none after merge).
2591
+ const existingActions = Array.isArray(existingFs.actions)
2592
+ ? existingFs.actions
2593
+ : null;
2594
+ const additionActions = Array.isArray(additionFs.actions)
2595
+ ? additionFs.actions
2596
+ : null;
2597
+ if (existingActions || additionActions) {
2598
+ merged.actions = [
2599
+ ...new Set([...(existingActions || []), ...(additionActions || [])]),
2600
+ ];
2601
+ }
2602
+ out.domains.fs = merged;
2562
2603
  }
2563
2604
  return out;
2564
2605
  }
2565
2606
 
2566
2607
  /**
2567
- * Async gate that escalates "no fs grant" denials to a JIT consent
2568
- * prompt when `opts.enableJit` is true. On approval, merges the
2569
- * decision's grant blob into the persisted grant and re-evaluates.
2608
+ * Async gate that escalates "missing in grant" fs denials to a JIT
2609
+ * consent prompt when `opts.enableJit` is true. Escalation signal is
2610
+ * STRUCTURAL, not message-based: if the requested action+filename
2611
+ * isn't covered by the widget's grant (action absent from `actions[]`,
2612
+ * or filename absent from the appropriate readPaths/writePaths), JIT
2613
+ * fires. Identity-resolution failures and malformed-args denials short-
2614
+ * circuit to the sync gate's verdict — those are abuse or caller bugs,
2615
+ * not consent gaps. Once the request IS covered by the grant, the sync
2616
+ * gate is authoritative (any denial it returns is structural).
2570
2617
  */
2571
2618
  async function gateFsCallWithJit$1(req, opts = {}) {
2572
- const initial = gateFsCall$1(req);
2573
- if (initial.allow) return initial;
2574
- if (!opts.enableJit) return initial;
2575
- if (!_isNoGrantDenial$1(initial.reason)) return initial;
2576
-
2577
- // Resolve verified identity once (token wins over claimed widgetId).
2578
- // Re-using the same resolution as gateFsCall keeps the JIT prompt
2579
- // and grant write tied to the same identity the gate just denied.
2619
+ if (!opts.enableJit) return gateFsCall$1(req);
2620
+
2621
+ // Identity must resolve to a concrete widgetId. Unknown tokens and
2622
+ // missing widgetId are returned by the sync gate — those denials
2623
+ // aren't recoverable via consent.
2580
2624
  const resolved = _resolveIdentity$2({
2581
2625
  token: req.token,
2582
2626
  widgetId: req.widgetId,
2583
2627
  });
2628
+ if (resolved.source === "token-unknown" || !resolved.widgetId) {
2629
+ return gateFsCall$1(req);
2630
+ }
2584
2631
  const verifiedWidgetId = resolved.widgetId;
2585
- if (!verifiedWidgetId) return initial;
2632
+
2633
+ // Args must be well-formed enough to derive a filename — malformed
2634
+ // calls are caller bugs, not consent gaps.
2635
+ const filename =
2636
+ req.args && typeof req.args === "object" ? req.args.filename : null;
2637
+ if (typeof filename !== "string" || !filename) {
2638
+ return gateFsCall$1(req);
2639
+ }
2640
+
2641
+ // Structural escalation: when the existing grant covers this
2642
+ // (action, filename) pair, the sync gate's verdict is authoritative.
2643
+ // Otherwise the request is a consent gap; escalate. Mirrors
2644
+ // permissionGate.gateToolCallWithJit's "tool in grant?" check.
2645
+ const grant = getGrant$3(verifiedWidgetId);
2646
+ const fsPerms = grant && grant.domains && grant.domains.fs;
2647
+ if (fsPerms) {
2648
+ const actionsAllow =
2649
+ !Array.isArray(fsPerms.actions) ||
2650
+ fsPerms.actions.length === 0 ||
2651
+ fsPerms.actions.includes(req.action);
2652
+ const isWrite = isFsWriteAction(req.action);
2653
+ const allowedPaths = isWrite
2654
+ ? fsPerms.writePaths || []
2655
+ : [...(fsPerms.readPaths || []), ...(fsPerms.writePaths || [])];
2656
+ const pathsAllow = _filenameMatches(filename, allowedPaths);
2657
+ if (actionsAllow && pathsAllow) {
2658
+ return gateFsCall$1(req);
2659
+ }
2660
+ }
2586
2661
 
2587
2662
  let decision;
2588
2663
  try {
@@ -2598,11 +2673,7 @@ async function gateFsCallWithJit$1(req, opts = {}) {
2598
2673
  } catch (e) {
2599
2674
  return {
2600
2675
  allow: false,
2601
- reason:
2602
- "JIT consent " +
2603
- (e && e.message ? e.message : "failed") +
2604
- "; original denial: " +
2605
- initial.reason,
2676
+ reason: "JIT consent " + (e && e.message ? e.message : "failed"),
2606
2677
  };
2607
2678
  }
2608
2679
 
@@ -2618,8 +2689,8 @@ async function gateFsCallWithJit$1(req, opts = {}) {
2618
2689
  };
2619
2690
  }
2620
2691
 
2621
- const filename = req.args?.filename || "*";
2622
- const isWrite = isFsWriteAction(req.action);
2692
+ const fallbackFilename = req.args?.filename || "*";
2693
+ const fallbackIsWrite = isFsWriteAction(req.action);
2623
2694
  const addition =
2624
2695
  decision.granted && typeof decision.granted === "object"
2625
2696
  ? decision.granted
@@ -2627,8 +2698,9 @@ async function gateFsCallWithJit$1(req, opts = {}) {
2627
2698
  grantOrigin: "live",
2628
2699
  domains: {
2629
2700
  fs: {
2630
- readPaths: !isWrite ? [filename] : [],
2631
- writePaths: isWrite ? [filename] : [],
2701
+ actions: [req.action],
2702
+ readPaths: !fallbackIsWrite ? [fallbackFilename] : [],
2703
+ writePaths: fallbackIsWrite ? [fallbackFilename] : [],
2632
2704
  },
2633
2705
  },
2634
2706
  };
@@ -2714,12 +2786,6 @@ function _resolveIdentity$1({ token, widgetId }) {
2714
2786
  return { widgetId: widgetId || null, source: "legacy" };
2715
2787
  }
2716
2788
 
2717
- function _isNoGrantDenial(reason) {
2718
- return (
2719
- typeof reason === "string" && /no network permissions granted/i.test(reason)
2720
- );
2721
- }
2722
-
2723
2789
  function _hostMatches(host, allowedList) {
2724
2790
  if (!Array.isArray(allowedList) || allowedList.length === 0) return false;
2725
2791
  if (allowedList.includes("*")) return true;
@@ -2805,6 +2871,23 @@ function gateNetworkCall$2({ widgetId, token, action, args }) {
2805
2871
  };
2806
2872
  }
2807
2873
 
2874
+ // Slice 4: per-action allowlist. Same Option A migration as fsGate —
2875
+ // missing/empty `actions[]` falls through to the legacy "any action
2876
+ // against allowed hosts" semantics so pre-slice grants keep working.
2877
+ if (Array.isArray(netPerms.actions) && netPerms.actions.length > 0) {
2878
+ if (!netPerms.actions.includes(action)) {
2879
+ return {
2880
+ allow: false,
2881
+ reason:
2882
+ "network gate: action '" +
2883
+ action +
2884
+ "' not in actions allowlist for widget '" +
2885
+ widgetId +
2886
+ "'",
2887
+ };
2888
+ }
2889
+ }
2890
+
2808
2891
  if (_hostMatches(host, netPerms.hosts)) {
2809
2892
  return { allow: true };
2810
2893
  }
@@ -2832,7 +2915,7 @@ function _mergeNetworkGrant(current, addition) {
2832
2915
  const additionNet = addition?.domains?.network;
2833
2916
  if (additionNet) {
2834
2917
  const existingNet = out.domains.network || { hosts: [] };
2835
- out.domains.network = {
2918
+ const merged = {
2836
2919
  hosts: [
2837
2920
  ...new Set([
2838
2921
  ...(existingNet.hosts || []),
@@ -2840,30 +2923,73 @@ function _mergeNetworkGrant(current, addition) {
2840
2923
  ]),
2841
2924
  ],
2842
2925
  };
2926
+ // Slice 4: union `actions[]`. Only emit when at least one side
2927
+ // declared it — preserves Option A migration.
2928
+ const existingActions = Array.isArray(existingNet.actions)
2929
+ ? existingNet.actions
2930
+ : null;
2931
+ const additionActions = Array.isArray(additionNet.actions)
2932
+ ? additionNet.actions
2933
+ : null;
2934
+ if (existingActions || additionActions) {
2935
+ merged.actions = [
2936
+ ...new Set([...(existingActions || []), ...(additionActions || [])]),
2937
+ ];
2938
+ }
2939
+ out.domains.network = merged;
2843
2940
  }
2844
2941
  return out;
2845
2942
  }
2846
2943
 
2847
2944
  /**
2848
- * Async gate that escalates "no network grant" denials to a JIT
2849
- * consent prompt when `opts.enableJit` is true. On approval, merges
2850
- * the decision's grant blob into the persisted grant and re-evaluates.
2945
+ * Async gate that escalates "missing in grant" network denials to a
2946
+ * JIT consent prompt when `opts.enableJit` is true. Escalation signal
2947
+ * is STRUCTURAL: if the requested action+host isn't covered by the
2948
+ * widget's grant (action absent from `actions[]`, or host absent from
2949
+ * `hosts[]`), JIT fires. Identity-resolution failures and malformed-
2950
+ * args / malformed-URL denials short-circuit to the sync verdict — not
2951
+ * recoverable via consent. Once the request IS covered, the sync gate
2952
+ * is authoritative.
2851
2953
  */
2852
2954
  async function gateNetworkCallWithJit$2(req, opts = {}) {
2853
- const initial = gateNetworkCall$2(req);
2854
- if (initial.allow) return initial;
2855
- if (!opts.enableJit) return initial;
2856
- if (!_isNoGrantDenial(initial.reason)) return initial;
2857
-
2858
- // Same identity-resolution as the sync gate — the JIT prompt and
2859
- // grant write must use the verified widgetId, not whatever the
2860
- // renderer claimed.
2955
+ if (!opts.enableJit) return gateNetworkCall$2(req);
2956
+
2957
+ // Identity must resolve. Unknown tokens / missing widgetId aren't
2958
+ // consent gaps.
2861
2959
  const resolved = _resolveIdentity$1({
2862
2960
  token: req.token,
2863
2961
  widgetId: req.widgetId,
2864
2962
  });
2963
+ if (resolved.source === "token-unknown" || !resolved.widgetId) {
2964
+ return gateNetworkCall$2(req);
2965
+ }
2865
2966
  const verifiedWidgetId = resolved.widgetId;
2866
- if (!verifiedWidgetId) return initial;
2967
+
2968
+ // Args must be well-formed enough to derive a host — malformed
2969
+ // calls aren't consent gaps.
2970
+ const url = req.args && typeof req.args === "object" ? req.args.url : null;
2971
+ if (typeof url !== "string" || !url) {
2972
+ return gateNetworkCall$2(req);
2973
+ }
2974
+ const host = _parseHost(url);
2975
+ if (!host) {
2976
+ return gateNetworkCall$2(req);
2977
+ }
2978
+
2979
+ // Structural escalation: when the existing grant covers this
2980
+ // (action, host) pair, the sync gate's verdict is authoritative.
2981
+ const grant = getGrant$2(verifiedWidgetId);
2982
+ const netPerms = grant && grant.domains && grant.domains.network;
2983
+ if (netPerms) {
2984
+ const actionsAllow =
2985
+ !Array.isArray(netPerms.actions) ||
2986
+ netPerms.actions.length === 0 ||
2987
+ netPerms.actions.includes(req.action);
2988
+ const hostsAllow = _hostMatches(host, netPerms.hosts || []);
2989
+ if (actionsAllow && hostsAllow) {
2990
+ return gateNetworkCall$2(req);
2991
+ }
2992
+ }
2867
2993
 
2868
2994
  let decision;
2869
2995
  try {
@@ -2879,11 +3005,7 @@ async function gateNetworkCallWithJit$2(req, opts = {}) {
2879
3005
  } catch (e) {
2880
3006
  return {
2881
3007
  allow: false,
2882
- reason:
2883
- "JIT consent " +
2884
- (e && e.message ? e.message : "failed") +
2885
- "; original denial: " +
2886
- initial.reason,
3008
+ reason: "JIT consent " + (e && e.message ? e.message : "failed"),
2887
3009
  };
2888
3010
  }
2889
3011
 
@@ -2899,13 +3021,14 @@ async function gateNetworkCallWithJit$2(req, opts = {}) {
2899
3021
  };
2900
3022
  }
2901
3023
 
2902
- const host = _parseHost(req.args?.url) || "*";
2903
3024
  const addition =
2904
3025
  decision.granted && typeof decision.granted === "object"
2905
3026
  ? decision.granted
2906
3027
  : {
2907
3028
  grantOrigin: "live",
2908
- domains: { network: { hosts: [host] } },
3029
+ domains: {
3030
+ network: { actions: [req.action], hosts: [host] },
3031
+ },
2909
3032
  };
2910
3033
  addition.grantOrigin = "live";
2911
3034