@underactive/pi-topping-web-tools 0.1.0 → 0.2.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.
@@ -7,7 +7,8 @@
7
7
  */
8
8
 
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { isPreapprovedHost } from "../permissions.ts";
10
+ import { isPreapprovedHost, permissionKey } from "../permissions.ts";
11
+ import { requestHostPermission } from "../permission-prompt.ts";
11
12
 
12
13
  export const MAX_URL_LENGTH = 2000;
13
14
 
@@ -44,6 +45,37 @@ export function validateURL(url: string): boolean {
44
45
  return parsed.protocol === "http:" || parsed.protocol === "https:";
45
46
  }
46
47
 
48
+ function isPrivateIpv4Octets(octets: number[]): boolean {
49
+ if (octets.some((n) => n > 255)) {
50
+ return false;
51
+ }
52
+ const [a, b] = octets;
53
+ return (
54
+ a === 127 ||
55
+ a === 0 ||
56
+ a === 10 ||
57
+ (a === 172 && b >= 16 && b <= 31) ||
58
+ (a === 192 && b === 168) ||
59
+ (a === 169 && b === 254)
60
+ );
61
+ }
62
+
63
+ function mappedIpv4Octets(suffix: string): number[] | undefined {
64
+ // Dotted-quad form: ::ffff:10.0.0.1
65
+ const dotted = /^(\d{1,3}(?:\.\d{1,3}){3})$/.exec(suffix);
66
+ if (dotted) {
67
+ return dotted[1].split(".").map((part) => Number.parseInt(part, 10));
68
+ }
69
+ // Two hex groups form: ::ffff:a00:1
70
+ const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(suffix);
71
+ if (hex) {
72
+ const high = Number.parseInt(hex[1], 16);
73
+ const low = Number.parseInt(hex[2], 16);
74
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff];
75
+ }
76
+ return undefined;
77
+ }
78
+
47
79
  export function isLocalOrPrivateHost(hostname: string): boolean {
48
80
  const host =
49
81
  hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
@@ -54,28 +86,23 @@ export function isLocalOrPrivateHost(hostname: string): boolean {
54
86
  if (host.endsWith(".local")) {
55
87
  return true;
56
88
  }
57
- if (/^(fc|fd|fe80:)/i.test(host) || /^::ffff:7f/i.test(host)) {
89
+ if (/^(fc|fd|fe80:)/i.test(host)) {
58
90
  return true;
59
91
  }
60
92
 
61
- if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) {
62
- return false;
93
+ const mapped = /^::ffff:(.+)$/i.exec(host);
94
+ if (mapped) {
95
+ const octets = mappedIpv4Octets(mapped[1]);
96
+ if (octets && isPrivateIpv4Octets(octets)) {
97
+ return true;
98
+ }
63
99
  }
64
100
 
65
- const octets = host.split(".").map((part) => Number.parseInt(part, 10));
66
- if (octets.some((n) => n > 255)) {
101
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) {
67
102
  return false;
68
103
  }
69
104
 
70
- const [a, b] = octets;
71
- if (a === 127) return true;
72
- if (a === 0) return true;
73
- if (a === 10) return true;
74
- if (a === 172 && b >= 16 && b <= 31) return true;
75
- if (a === 192 && b === 168) return true;
76
- if (a === 169 && b === 254) return true;
77
-
78
- return false;
105
+ return isPrivateIpv4Octets(host.split(".").map((part) => Number.parseInt(part, 10)));
79
106
  }
80
107
 
81
108
  export function upgradeHttpToHttps(url: string): string {
@@ -91,18 +118,6 @@ export function upgradeHttpToHttps(url: string): string {
91
118
  return url;
92
119
  }
93
120
 
94
- export function permissionKey(url: string): string {
95
- try {
96
- const parsed = new URL(url);
97
- if (parsed.protocol === "file:") {
98
- return `file://${parsed.pathname}`;
99
- }
100
- return `${parsed.protocol}//${parsed.host}`;
101
- } catch {
102
- return url;
103
- }
104
- }
105
-
106
121
  export type PermissionResult = "allow" | `block:${string}`;
107
122
 
108
123
  export async function checkUrlPermission(
@@ -131,32 +146,19 @@ export async function checkUrlPermission(
131
146
  }
132
147
 
133
148
  const key = permissionKey(url);
134
- const sessionDecision = sessionPermissions.get(key);
135
- if (sessionDecision === "allow") {
149
+ const isDurable = protocol !== "file:";
150
+ const label = isDurable ? `Allow web_browser to ${hostname}?` : `Allow web_browser to local file ${pathname}?`;
151
+
152
+ const result = await requestHostPermission(ctx, {
153
+ scope: "web_browser",
154
+ label,
155
+ key,
156
+ sessionPermissions,
157
+ durable: isDurable,
158
+ });
159
+
160
+ if (result.allowed) {
136
161
  return "allow";
137
162
  }
138
- if (sessionDecision === "deny") {
139
- return `block:Denied by user for ${key}`;
140
- }
141
-
142
- if (!ctx.hasUI) {
143
- return `block:web_browser to ${key} blocked (no UI for confirmation)`;
144
- }
145
-
146
- const label = protocol === "file:" ? `Allow web_browser to local file ${pathname}?` : `Allow web_browser to ${hostname}?`;
147
- const choice = await ctx.ui.select(label, ["Allow once", "Allow for this session", "Deny"]);
148
-
149
- if (choice === "Allow for this session") {
150
- sessionPermissions.set(key, "allow");
151
- return "allow";
152
- }
153
- if (choice === "Deny") {
154
- sessionPermissions.set(key, "deny");
155
- return `block:Denied by user for ${key}`;
156
- }
157
- if (choice === "Allow once") {
158
- return "allow";
159
- }
160
-
161
- return `block:Denied by user for ${key}`;
163
+ return `block:${result.reason}`;
162
164
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * /web-permissions — list and revoke saved web-tool host permissions.
3
+ *
4
+ * Shows all active durable grants from the permission store.
5
+ * Users can select entries to revoke them. Bundled preapproved hosts
6
+ * (from the shared allowlist) are not listed and cannot be deleted here.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { listGrants, removeGrant, type Grant } from "./permission-store.ts";
11
+
12
+ function formatRemaining(expiresAt: number): string {
13
+ const ms = expiresAt - Date.now();
14
+ if (ms <= 0) return "expired";
15
+ const hours = Math.floor(ms / (60 * 60 * 1000));
16
+ const days = Math.floor(hours / 24);
17
+ const remHours = hours % 24;
18
+ if (days > 0) return `${days}d ${remHours}h`;
19
+ return `${hours}h`;
20
+ }
21
+
22
+ function formatEntry(grant: Grant): string {
23
+ return `${grant.origin} \u2014 ${grant.scope} \u2014 expires in ${formatRemaining(grant.expiresAt)}`;
24
+ }
25
+
26
+ export default function (pi: ExtensionAPI) {
27
+ pi.registerCommand("web-permissions", {
28
+ description: "List and revoke saved web-tool host permissions",
29
+ handler: async (_args, ctx) => {
30
+ if (!ctx.hasUI) {
31
+ ctx.ui.notify("The /web-permissions command requires an interactive session.", "warning");
32
+ return;
33
+ }
34
+
35
+ while (true) {
36
+ const grants = listGrants();
37
+
38
+ if (grants.length === 0) {
39
+ ctx.ui.notify(
40
+ "No saved host permissions. Bundled preapproved hosts still apply.",
41
+ "info",
42
+ );
43
+ return;
44
+ }
45
+
46
+ grants.sort((a, b) => a.origin.localeCompare(b.origin) || a.scope.localeCompare(b.scope));
47
+
48
+ const options = [...grants.map(formatEntry), "Done"];
49
+ const choice = await ctx.ui.select("Saved host permissions:", options);
50
+
51
+ if (choice === "Done" || choice === undefined) {
52
+ return;
53
+ }
54
+
55
+ // Find the selected grant by matching the formatted entry.
56
+ const index = grants.findIndex((g) => formatEntry(g) === choice);
57
+ if (index === -1) continue;
58
+
59
+ const grant = grants[index];
60
+ const confirmed = await ctx.ui.confirm(
61
+ "Remove permission?",
62
+ `${grant.scope} \u2192 ${grant.origin}`,
63
+ );
64
+
65
+ if (confirmed) {
66
+ await removeGrant(grant.scope, grant.origin);
67
+ ctx.ui.notify(`Removed permission for ${grant.origin}`, "info");
68
+ }
69
+ // Loop back to show updated list.
70
+ }
71
+ },
72
+ });
73
+ }