poltertab 1.9.0-beta.2 → 1.10.0-beta.1

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/bin/poltertab.js CHANGED
@@ -25,6 +25,11 @@ if (arg === "setup") {
25
25
  });
26
26
  } else if (arg === "--version" || arg === "-v") {
27
27
  console.log(require("../package.json").version);
28
+ } else if (arg === "uploads") {
29
+ require("./uploads.js").run(process.argv.slice(3)).catch((err) => {
30
+ console.error(`\n uploads failed: ${err.message}\n`);
31
+ process.exit(1);
32
+ });
28
33
  } else if (arg === "--help" || arg === "-h") {
29
34
  console.log(`
30
35
  poltertab — browser control for AI agents
@@ -32,10 +37,16 @@ if (arg === "setup") {
32
37
  poltertab start the MCP server (stdio)
33
38
  poltertab setup install skill, CLAUDE.md section, and MCP registration
34
39
  poltertab doctor versions, update check, and extension/server skew
40
+ poltertab uploads manage upload-allowed folders and view the activity log
35
41
  poltertab --version
36
42
 
37
43
  setup flags: --global | --project (skips the prompt)
38
44
  doctor flags: --force (ignore the cached update check), --port <n>
45
+ uploads:
46
+ poltertab uploads list show allowed folders, always-blocked, and status
47
+ poltertab uploads allow <dir> add a directory to the allowlist
48
+ poltertab uploads revoke <dir> remove a directory from the allowlist
49
+ poltertab uploads log [-n N] tail the upload activity log (default: 20 lines)
39
50
 
40
51
  POLTERTAB_HOME where memory and downloads live
41
52
  POLTERTAB_NO_UPDATE_CHECK set to disable the npm version check
package/bin/uploads.js ADDED
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+
3
+ // poltertab uploads — manage the upload allowlist and view the activity log.
4
+ // Works with no server or extension running; edits config.json directly.
5
+
6
+ const os = require("os");
7
+ const {
8
+ getAllowlist,
9
+ addAllowlistEntry,
10
+ removeAllowlistEntry,
11
+ addTrustedExtensionId,
12
+ } = require("../mcp-server/uploads-config.js");
13
+ const { getRecentLog } = require("../mcp-server/uploads-log.js");
14
+ const { validateAllowlistEntry, buildAlwaysBlocked } = require("../mcp-server/upload-paths.js");
15
+
16
+ async function run(argv) {
17
+ const sub = argv[0];
18
+
19
+ if (!sub || sub === "list") {
20
+ const homeDir = os.homedir();
21
+ const allowlist = getAllowlist();
22
+ const alwaysBlocked = buildAlwaysBlocked(homeDir);
23
+
24
+ console.log("\nUpload configuration (~/.poltertab/config.json)");
25
+ console.log("─".repeat(50));
26
+
27
+ if (allowlist.length === 0) {
28
+ console.log("\n Allowed folders: (none — uploads disabled)");
29
+ } else {
30
+ console.log("\n Allowed folders:");
31
+ for (const dir of allowlist) {
32
+ console.log(` + ${dir}`);
33
+ }
34
+ }
35
+
36
+ console.log("\n Always blocked (cannot be changed):");
37
+ for (const dir of alwaysBlocked) {
38
+ console.log(` ✗ ${dir}`);
39
+ }
40
+ console.log(` ✗ ~/.* (home dotfiles)`);
41
+ console.log();
42
+ return;
43
+ }
44
+
45
+ if (sub === "allow") {
46
+ const dir = argv[1];
47
+ if (!dir) {
48
+ console.error("Usage: poltertab uploads allow <dir>");
49
+ process.exit(1);
50
+ }
51
+ const v = validateAllowlistEntry(dir);
52
+ if (!v.ok) {
53
+ console.error(`\n Error: ${v.errorText}\n`);
54
+ process.exit(1);
55
+ }
56
+ const added = addAllowlistEntry(dir);
57
+ if (added) {
58
+ console.log(`\n Added: ${dir}\n`);
59
+ } else {
60
+ console.log(`\n Already present: ${dir}\n`);
61
+ }
62
+ return;
63
+ }
64
+
65
+ if (sub === "revoke") {
66
+ const dir = argv[1];
67
+ if (!dir) {
68
+ console.error("Usage: poltertab uploads revoke <dir>");
69
+ process.exit(1);
70
+ }
71
+ const removed = removeAllowlistEntry(dir);
72
+ if (removed) {
73
+ console.log(`\n Removed: ${dir}\n`);
74
+ } else {
75
+ console.log(`\n Not found in allowlist: ${dir}\n`);
76
+ }
77
+ return;
78
+ }
79
+
80
+ if (sub === "log") {
81
+ let n = 20;
82
+ const nIdx = argv.indexOf("-n");
83
+ if (nIdx !== -1 && argv[nIdx + 1]) {
84
+ n = parseInt(argv[nIdx + 1], 10) || 20;
85
+ }
86
+ const entries = getRecentLog(n);
87
+ if (entries.length === 0) {
88
+ console.log("\n No upload activity recorded.\n");
89
+ return;
90
+ }
91
+ console.log(`\nUpload activity log (last ${entries.length} entries)`);
92
+ console.log("─".repeat(50));
93
+ for (const e of entries) {
94
+ const paths = Array.isArray(e.paths) ? e.paths.join(", ") : String(e.paths);
95
+ console.log(` ${e.ts} [${e.outcome}] method=${e.method} ${paths}`);
96
+ }
97
+ console.log();
98
+ return;
99
+ }
100
+
101
+ if (sub === "pair") {
102
+ await runPair(argv.slice(1));
103
+ return;
104
+ }
105
+
106
+ console.error(`\n Unknown subcommand: ${sub}`);
107
+ console.error(" Usage: poltertab uploads list|allow <dir>|revoke <dir>|log [-n N]|pair [--id <id>]\n");
108
+ process.exit(1);
109
+ }
110
+
111
+ // Reads the port poltertab is listening on from the default config or falls back.
112
+ function resolveWsPort() {
113
+ try {
114
+ const { resolvePort } = require("../mcp-server/config.js");
115
+ return resolvePort();
116
+ } catch (_) {
117
+ return 7822;
118
+ }
119
+ }
120
+
121
+ // poltertab uploads pair — link a Chrome extension by its ID so it may send
122
+ // upload config/log WS messages. Interactive by default (requires TTY);
123
+ // --id <id> skips the prompt for scripted human use.
124
+ async function runPair(argv) {
125
+ const idFlagIdx = argv.indexOf("--id");
126
+ const flagId = idFlagIdx !== -1 ? argv[idFlagIdx + 1] : null;
127
+
128
+ if (!flagId && !process.stdin.isTTY) {
129
+ console.error("\nError: poltertab uploads pair requires an interactive terminal.\n" +
130
+ "Use --id <extension-id> to pair non-interactively.\n");
131
+ process.exit(1);
132
+ }
133
+
134
+ let extensionId = flagId;
135
+
136
+ if (!extensionId) {
137
+ // Ask the running server which extension origins are connected.
138
+ const port = resolveWsPort();
139
+ let origins = [];
140
+ try {
141
+ origins = await fetchConnectedOrigins(port);
142
+ } catch (err) {
143
+ console.error(`\nCould not connect to poltertab server on port ${port}: ${err.message}`);
144
+ console.error("Make sure poltertab is running, then try again.\n");
145
+ process.exit(1);
146
+ }
147
+
148
+ const extIds = origins
149
+ .filter((o) => o.startsWith("chrome-extension://"))
150
+ .map((o) => o.slice("chrome-extension://".length).replace(/\/$/, ""));
151
+
152
+ if (extIds.length === 0) {
153
+ console.error("\nNo extensions are currently connected to poltertab.");
154
+ console.error("Make sure the poltertab Chrome extension is installed and active, then try again.\n");
155
+ process.exit(1);
156
+ }
157
+
158
+ console.log("\nConnected extension IDs:");
159
+ extIds.forEach((id, i) => console.log(` ${i + 1}. ${id}`));
160
+ console.log("\nOpen chrome://extensions to find which ID matches your poltertab extension.");
161
+
162
+ const readline = require("readline");
163
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
164
+ const choice = await new Promise((resolve) => {
165
+ rl.question(
166
+ `\nCompare the ID(s) above with chrome://extensions to find your poltertab extension.\n` +
167
+ `Enter number (1-${extIds.length}) to pair, or paste the ID directly: `,
168
+ (ans) => {
169
+ rl.close();
170
+ resolve(ans.trim());
171
+ },
172
+ );
173
+ });
174
+ const num = parseInt(choice, 10);
175
+ if (!isNaN(num) && num >= 1 && num <= extIds.length) {
176
+ extensionId = extIds[num - 1];
177
+ } else if (/^[a-p]{32}$/.test(choice)) {
178
+ extensionId = choice;
179
+ } else {
180
+ console.error("\nInvalid selection.\n");
181
+ process.exit(1);
182
+ }
183
+ }
184
+
185
+ if (!/^[a-p]{32}$/.test(extensionId)) {
186
+ console.error(`\nInvalid extension ID: ${extensionId}`);
187
+ console.error("Chrome extension IDs are 32 lowercase letters.\n");
188
+ process.exit(1);
189
+ }
190
+
191
+ const added = addTrustedExtensionId(extensionId);
192
+ if (added) {
193
+ console.log(`\n Paired: ${extensionId}`);
194
+ console.log(" Upload config access is now enabled for this extension.\n");
195
+ } else {
196
+ console.log(`\n Already paired: ${extensionId}\n`);
197
+ }
198
+ }
199
+
200
+ // Ask the WS server for connected extension origins via uploads_get_origins.
201
+ function fetchConnectedOrigins(port) {
202
+ return new Promise((resolve, reject) => {
203
+ let WebSocket;
204
+ try {
205
+ WebSocket = require("ws");
206
+ } catch (_) {
207
+ reject(new Error("ws module not available"));
208
+ return;
209
+ }
210
+ const ws = new WebSocket(`ws://localhost:${port}`);
211
+ const timer = setTimeout(() => {
212
+ ws.terminate();
213
+ reject(new Error("timeout waiting for server response"));
214
+ }, 5000);
215
+ ws.on("open", () => {
216
+ ws.send(JSON.stringify({ type: "uploads_get_origins" }));
217
+ });
218
+ ws.on("message", (raw) => {
219
+ clearTimeout(timer);
220
+ try {
221
+ const msg = JSON.parse(raw);
222
+ if (msg.type === "uploads_origins") {
223
+ ws.close();
224
+ resolve(Array.isArray(msg.origins) ? msg.origins : []);
225
+ }
226
+ } catch (_) {
227
+ ws.close();
228
+ reject(new Error("Invalid response from server"));
229
+ }
230
+ });
231
+ ws.on("error", (err) => {
232
+ clearTimeout(timer);
233
+ reject(err);
234
+ });
235
+ });
236
+ }
237
+
238
+ module.exports = { run };
@@ -35,6 +35,37 @@ const {
35
35
  } = require("./config.js");
36
36
  const updates = require("./update-check.js");
37
37
  const OWN_VERSION = require("./../package.json").version;
38
+ const { getAllowlist, addAllowlistEntry, removeAllowlistEntry, getTrustedExtensionIds } = require("./uploads-config.js");
39
+ const { logUpload, getRecentLog } = require("./uploads-log.js");
40
+ const { buildAlwaysBlocked, validateAllowlistEntry } = require("./upload-paths.js");
41
+ const fs = require("fs");
42
+ const path = require("path");
43
+
44
+ // ── Trusted extension IDs ─────────────────────────────────────────────────
45
+ // Upload config/log WS messages are restricted to origins paired via
46
+ // `poltertab uploads pair`. The CWS-assigned ID will go here once the Store
47
+ // publishes the extension; until then it is an empty string (unused).
48
+ //
49
+ // Caveat (design §4): a non-browser local process can forge any Origin header;
50
+ // this gate stops other Chrome extensions, not a hostile process with raw socket
51
+ // access. The always-blocked list and allowlist are the server-side guards.
52
+ const CWS_EXTENSION_ID = ""; // filled when the Chrome Web Store assigns it
53
+
54
+ // isUploadsAuthorized and isPaired may throw on malformed config.json —
55
+ // callers must catch and send uploads_error.
56
+ function isUploadsAuthorized(origin) {
57
+ if (!origin || !origin.startsWith("chrome-extension://")) return false;
58
+ const id = origin.slice("chrome-extension://".length).replace(/\/$/, "");
59
+ const ids = new Set(getTrustedExtensionIds());
60
+ if (CWS_EXTENSION_ID) ids.add(CWS_EXTENSION_ID);
61
+ return ids.has(id);
62
+ }
63
+
64
+ function isPaired() {
65
+ const ids = new Set(getTrustedExtensionIds());
66
+ if (CWS_EXTENSION_ID) ids.add(CWS_EXTENSION_ID);
67
+ return ids.size > 0;
68
+ }
38
69
 
39
70
  let WS_PORT = resolvePort();
40
71
 
@@ -841,8 +872,9 @@ function _dispatchProxyNow(callerWs, id, action, params) {
841
872
  }
842
873
 
843
874
  function setupPrimaryWss(wss) {
844
- wss.on("connection", (ws) => {
875
+ wss.on("connection", (ws, req) => {
845
876
  ws.isAlive = true;
877
+ ws.handshakeOrigin = (req && req.headers && req.headers.origin) || null;
846
878
  ws.supportsNewCloseCodes = false;
847
879
  ws.on("pong", () => {
848
880
  ws.isAlive = true;
@@ -954,6 +986,28 @@ function setupPrimaryWss(wss) {
954
986
  broadcastSessionState();
955
987
  }
956
988
 
989
+ // Self-navigation guard: refuse commands targeting extension pages.
990
+ const EXTENSION_SENSITIVE_ACTIONS = new Set([
991
+ "navigate", "tab_new", "click", "fill", "snapshot", "scrape", "extract",
992
+ ]);
993
+ if (EXTENSION_SENSITIVE_ACTIONS.has(action)) {
994
+ const urlParam = params.url || params.target || "";
995
+ let isSelfNav = false;
996
+ if (typeof urlParam === "string" && urlParam) {
997
+ try { isSelfNav = new URL(urlParam).protocol.toLowerCase() === "chrome-extension:"; } catch (_) {}
998
+ }
999
+ if (isSelfNav) {
1000
+ if (ws.readyState === WebSocket.OPEN) {
1001
+ ws.send(JSON.stringify({
1002
+ id,
1003
+ success: false,
1004
+ error: "[self_navigation_refused] Agent commands cannot target extension pages.",
1005
+ }));
1006
+ }
1007
+ return;
1008
+ }
1009
+ }
1010
+
957
1011
  if (action === "get_network_state") {
958
1012
  (async () => {
959
1013
  try {
@@ -1101,6 +1155,132 @@ function setupPrimaryWss(wss) {
1101
1155
  return;
1102
1156
  }
1103
1157
 
1158
+ // uploads_get_origins — return list of connected extension origins.
1159
+ // Used by `poltertab uploads pair` to discover which extension to trust.
1160
+ // Only originless (CLI/local) sockets may request this; chrome-extension://
1161
+ // origins must not be able to enumerate other connected extensions.
1162
+ if (msg.type === "uploads_get_origins") {
1163
+ if (ws.handshakeOrigin) {
1164
+ // Extension socket — refuse; only CLI callers (no origin) may enumerate.
1165
+ if (ws.readyState === WebSocket.OPEN) {
1166
+ ws.send(JSON.stringify({ type: "uploads_error", error: "uploads_get_origins is restricted to local CLI callers" }));
1167
+ }
1168
+ return;
1169
+ }
1170
+ const origins = [];
1171
+ if (wss) {
1172
+ for (const client of wss.clients) {
1173
+ if (client.handshakeOrigin && client.handshakeOrigin.startsWith("chrome-extension://")) {
1174
+ origins.push(client.handshakeOrigin);
1175
+ }
1176
+ }
1177
+ }
1178
+ if (ws.readyState === WebSocket.OPEN) {
1179
+ ws.send(JSON.stringify({ type: "uploads_origins", origins }));
1180
+ }
1181
+ return;
1182
+ }
1183
+
1184
+ // Upload config/log messages — paired extension origin only.
1185
+ // Requires the origin to be in trusted_extension_ids (via `poltertab uploads pair`).
1186
+ // Until an ID is paired, returns uploads_not_paired.
1187
+ if (
1188
+ msg.type === "uploads_get_config" ||
1189
+ msg.type === "uploads_set_config" ||
1190
+ msg.type === "uploads_get_log"
1191
+ ) {
1192
+ let pairedOk, authorizedOk;
1193
+ try {
1194
+ pairedOk = isPaired();
1195
+ authorizedOk = isUploadsAuthorized(ws.handshakeOrigin);
1196
+ } catch (cfgErr) {
1197
+ if (ws.readyState === WebSocket.OPEN) {
1198
+ ws.send(JSON.stringify({
1199
+ type: "uploads_error",
1200
+ _replyId: msg._replyId || null,
1201
+ error: "config read error: " + cfgErr.message,
1202
+ }));
1203
+ }
1204
+ return;
1205
+ }
1206
+ if (!pairedOk) {
1207
+ if (ws.readyState === WebSocket.OPEN) {
1208
+ ws.send(JSON.stringify({
1209
+ type: "uploads_not_paired",
1210
+ _replyId: msg._replyId || null,
1211
+ error: "Run poltertab uploads pair to link your extension",
1212
+ }));
1213
+ }
1214
+ return;
1215
+ }
1216
+ if (!authorizedOk) {
1217
+ if (ws.readyState === WebSocket.OPEN) {
1218
+ ws.send(JSON.stringify({
1219
+ type: "uploads_not_paired",
1220
+ _replyId: msg._replyId || null,
1221
+ error: "Run poltertab uploads pair to link your extension",
1222
+ }));
1223
+ }
1224
+ return;
1225
+ }
1226
+ const os = require("os");
1227
+ const homeDir = os.homedir();
1228
+
1229
+ if (msg.type === "uploads_get_config") {
1230
+ const allowlist = getAllowlist();
1231
+ const alwaysBlocked = buildAlwaysBlocked(homeDir);
1232
+ if (ws.readyState === WebSocket.OPEN) {
1233
+ ws.send(JSON.stringify({
1234
+ type: "uploads_config",
1235
+ _replyId: msg._replyId || null,
1236
+ allowlist,
1237
+ alwaysBlocked,
1238
+ }));
1239
+ }
1240
+ return;
1241
+ }
1242
+
1243
+ if (msg.type === "uploads_set_config") {
1244
+ const { action: cfgAction, dir } = msg;
1245
+ let result;
1246
+ if (cfgAction === "add") {
1247
+ const v = validateAllowlistEntry(dir, homeDir);
1248
+ if (!v.ok) {
1249
+ result = { ok: false, error: v.errorText };
1250
+ } else {
1251
+ addAllowlistEntry(dir);
1252
+ result = { ok: true };
1253
+ }
1254
+ } else if (cfgAction === "remove") {
1255
+ removeAllowlistEntry(dir);
1256
+ result = { ok: true };
1257
+ } else {
1258
+ result = { ok: false, error: `Unknown action: ${cfgAction}` };
1259
+ }
1260
+ if (ws.readyState === WebSocket.OPEN) {
1261
+ ws.send(JSON.stringify({
1262
+ type: "uploads_config_result",
1263
+ _replyId: msg._replyId || null,
1264
+ ...result,
1265
+ }));
1266
+ }
1267
+ return;
1268
+ }
1269
+
1270
+ if (msg.type === "uploads_get_log") {
1271
+ const n = Math.min(typeof msg.n === "number" ? msg.n : 20, 500);
1272
+ const entries = getRecentLog(n);
1273
+ if (ws.readyState === WebSocket.OPEN) {
1274
+ ws.send(JSON.stringify({
1275
+ type: "uploads_log",
1276
+ _replyId: msg._replyId || null,
1277
+ entries,
1278
+ }));
1279
+ }
1280
+ return;
1281
+ }
1282
+ }
1283
+
1104
1284
  // Handle captured network data
1105
1285
  if (msg.type === "network_data") {
1106
1286
  const { tabId, url, body } = msg;
@@ -1271,6 +1451,23 @@ function _buildEnvelope(action, params, activeNodeId) {
1271
1451
  // Helper to send command to the extension
1272
1452
  async function sendCommand(action, params) {
1273
1453
  if (incompatible) throw new Error(incompatibleError());
1454
+
1455
+ // Self-navigation guard: refuse commands targeting extension pages.
1456
+ // Mirrors the proxy_command guard; covers the Primary's own tool calls.
1457
+ const SENDCMD_SENSITIVE = new Set([
1458
+ "navigate", "tab_new", "click", "fill", "snapshot", "scrape", "extract",
1459
+ ]);
1460
+ if (SENDCMD_SENSITIVE.has(action) && params) {
1461
+ const urlParam = params.url || params.target || "";
1462
+ if (typeof urlParam === "string" && urlParam) {
1463
+ let isSelfNav = false;
1464
+ try { isSelfNav = new URL(urlParam).protocol.toLowerCase() === "chrome-extension:"; } catch (_) {}
1465
+ if (isSelfNav) {
1466
+ throw new Error("[self_navigation_refused] Agent commands cannot target extension pages.");
1467
+ }
1468
+ }
1469
+ }
1470
+
1274
1471
  // Evicted, or a caller ahead of this one is already reconnecting: wait for
1275
1472
  // that one dial rather than racing it.
1276
1473
  if (isEvicted || reconnect) await lazyReconnect();
@@ -36,6 +36,7 @@ const {
36
36
  const { extractAll } = require("./extract-all.js");
37
37
  const bridge = require("./bridge.js");
38
38
  const { validateUploadPaths, MAX_BYTES } = require("./upload-paths.js");
39
+ const { logUpload } = require("./uploads-log.js");
39
40
  const OWN_VERSION = require("./../package.json").version;
40
41
  const fs = require("fs");
41
42
  const path = require("path");
@@ -222,6 +223,7 @@ const handleToolCall = async (request) => {
222
223
  checkSize: method === "drop",
223
224
  });
224
225
  if (!validation.ok) {
226
+ try { logUpload({ paths: filePaths, method, outcome: `denied:${validation.errorText}`, origin: null, session: opts.session || null }); } catch (_) {}
225
227
  return {
226
228
  content: [{ type: "text", text: validation.errorText }],
227
229
  isError: true,
@@ -261,7 +263,14 @@ const handleToolCall = async (request) => {
261
263
  `[poltertab] upload_file method=${method} files=${filePaths.join(", ")}\n`,
262
264
  );
263
265
 
264
- const result = await bridge.sendCommand("upload_file", bridgeParams);
266
+ let result;
267
+ try {
268
+ result = await bridge.sendCommand("upload_file", bridgeParams);
269
+ } catch (uploadErr) {
270
+ try { logUpload({ paths: filePaths, method, outcome: "error", origin: null, session: opts.session || null }); } catch (_) {}
271
+ throw uploadErr;
272
+ }
273
+ try { logUpload({ paths: filePaths, method, outcome: "ok", origin: null, session: opts.session || null }); } catch (_) {}
265
274
  bridge.noteActiveTab(result && result.tabId);
266
275
  return {
267
276
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
@@ -70,9 +70,9 @@ function validateUploadPaths(filePaths, config) {
70
70
  return {
71
71
  ok: false,
72
72
  errorText:
73
- "File uploads are disabled. To enable them, add an upload_allowed_paths array " +
74
- "to ~/.poltertab/config.json listing the directories Claude may read files from.\n\n" +
75
- "Example:\n{\n \"upload_allowed_paths\": [\"/Users/you/Documents\", \"/Users/you/Downloads\"]\n}",
73
+ "File uploads are disabled. The user can run `poltertab uploads allow <dir>` to permit a " +
74
+ "directory, or open the extension options page to manage allowed folders.\n\n" +
75
+ "Example:\n poltertab uploads allow ~/Documents",
76
76
  };
77
77
  }
78
78
 
@@ -139,4 +139,74 @@ function validateUploadPaths(filePaths, config) {
139
139
  return { ok: true, resolvedPaths };
140
140
  }
141
141
 
142
- module.exports = { validateUploadPaths, MAX_BYTES };
142
+ /**
143
+ * Validate a single path for addition to the allowlist.
144
+ * Accepts absolute paths and ~/relative paths. Rejects relative paths and
145
+ * paths inside the always-blocked set. Accepts nonexistent directories.
146
+ *
147
+ * @param {string} dir - The path the user wants to allow.
148
+ * @param {string} [homeDir] - Override os.homedir() (tests only).
149
+ * @returns {{ ok: true } | { ok: false, errorText: string }}
150
+ */
151
+ function validateAllowlistEntry(dir, homeDir = os.homedir()) {
152
+ if (!dir || typeof dir !== "string") {
153
+ return { ok: false, errorText: "Path must be a non-empty string." };
154
+ }
155
+
156
+ if (!path.isAbsolute(dir) && !dir.startsWith("~/")) {
157
+ return {
158
+ ok: false,
159
+ errorText: `"${dir}" is a relative path. Use an absolute path or ~/relative form.`,
160
+ };
161
+ }
162
+
163
+ const expanded = dir.startsWith("~/")
164
+ ? path.join(homeDir, dir.slice(2))
165
+ : dir;
166
+ let resolved = path.resolve(expanded);
167
+
168
+ // For existing paths: realpath so symlinks (e.g. ~/link-to-ssh) are caught
169
+ // by the always-blocked check below; also verify it is a directory.
170
+ try {
171
+ const real = fs.realpathSync(resolved);
172
+ const stat = fs.statSync(real);
173
+ if (!stat.isDirectory()) {
174
+ return {
175
+ ok: false,
176
+ errorText: `"${dir}" is a file, not a directory. Only directories can be added to the allowlist.`,
177
+ };
178
+ }
179
+ resolved = real;
180
+ } catch (_) {
181
+ // Path does not yet exist — accepted so future-created dirs can be pre-allowed.
182
+ }
183
+
184
+ const alwaysBlocked = buildAlwaysBlocked(homeDir);
185
+ for (const blocked of alwaysBlocked) {
186
+ if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
187
+ return {
188
+ ok: false,
189
+ errorText: `"${dir}" is inside a protected directory (${blocked}) and cannot be added.`,
190
+ };
191
+ }
192
+ }
193
+
194
+ // Deny home dotfiles
195
+ const rel = path.relative(homeDir, resolved);
196
+ if (!rel.startsWith("..") && rel.startsWith(".") && !rel.includes(path.sep)) {
197
+ return {
198
+ ok: false,
199
+ errorText: `"${dir}" is a home directory dotfile and cannot be added.`,
200
+ };
201
+ }
202
+
203
+ return { ok: true };
204
+ }
205
+
206
+ module.exports = {
207
+ validateUploadPaths,
208
+ validateAllowlistEntry,
209
+ buildAlwaysBlocked,
210
+ expandAllowlistEntry,
211
+ MAX_BYTES,
212
+ };
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ // Atomic read-modify-write for the upload_allowed_paths key in config.json.
4
+ // Preserves all other keys in config.json. Atomic write = temp file + rename.
5
+ //
6
+ // Used by bridge.js (WS messages) and bin/uploads.js (CLI).
7
+
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+ const { POLTERTAB_HOME } = require("./config.js");
12
+
13
+ const CONFIG_PATH = path.join(POLTERTAB_HOME, "config.json");
14
+
15
+ function _readConfig() {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
18
+ } catch (err) {
19
+ if (err.code === "ENOENT") return {};
20
+ throw new Error("config.json is not valid JSON — fix it before editing the allowlist");
21
+ }
22
+ }
23
+
24
+ function _writeConfig(data) {
25
+ fs.mkdirSync(POLTERTAB_HOME, { recursive: true });
26
+ const tmp = CONFIG_PATH + ".tmp." + process.pid;
27
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
28
+ fs.renameSync(tmp, CONFIG_PATH);
29
+ }
30
+
31
+ function getAllowlist() {
32
+ const cfg = _readConfig();
33
+ const v = cfg.upload_allowed_paths;
34
+ return Array.isArray(v) ? v : [];
35
+ }
36
+
37
+ // Adds dir to the allowlist if not already present. Returns true if added,
38
+ // false if already present. Throws if validation fails (call
39
+ // validateAllowlistEntry first to surface user-facing messages).
40
+ function addAllowlistEntry(dir) {
41
+ const cfg = _readConfig();
42
+ const list = Array.isArray(cfg.upload_allowed_paths) ? cfg.upload_allowed_paths : [];
43
+ if (list.includes(dir)) return false;
44
+ _writeConfig({ ...cfg, upload_allowed_paths: [...list, dir] });
45
+ return true;
46
+ }
47
+
48
+ // Removes dir from the allowlist. Returns true if removed, false if absent.
49
+ function removeAllowlistEntry(dir) {
50
+ const cfg = _readConfig();
51
+ const list = Array.isArray(cfg.upload_allowed_paths) ? cfg.upload_allowed_paths : [];
52
+ const next = list.filter((e) => e !== dir);
53
+ if (next.length === list.length) return false;
54
+ _writeConfig({ ...cfg, upload_allowed_paths: next });
55
+ return true;
56
+ }
57
+
58
+ // Adds id to trusted_extension_ids if not already present. Returns true if added.
59
+ function addTrustedExtensionId(id) {
60
+ const cfg = _readConfig();
61
+ const list = Array.isArray(cfg.trusted_extension_ids) ? cfg.trusted_extension_ids : [];
62
+ if (list.includes(id)) return false;
63
+ _writeConfig({ ...cfg, trusted_extension_ids: [...list, id] });
64
+ return true;
65
+ }
66
+
67
+ function getTrustedExtensionIds() {
68
+ const cfg = _readConfig();
69
+ const v = cfg.trusted_extension_ids;
70
+ return Array.isArray(v) ? v : [];
71
+ }
72
+
73
+ module.exports = {
74
+ getAllowlist,
75
+ addAllowlistEntry,
76
+ removeAllowlistEntry,
77
+ addTrustedExtensionId,
78
+ getTrustedExtensionIds,
79
+ CONFIG_PATH,
80
+ };
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+
3
+ // Append + rotate upload activity log at ~/.poltertab/uploads.log.jsonl.
4
+ // Each entry: { ts, paths, method, outcome, origin, session }.
5
+ // Rotation: drop oldest lines until the file is ≤ LOG_MAX_BYTES.
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const { POLTERTAB_HOME } = require("./config.js");
10
+
11
+ const LOG_PATH = path.join(POLTERTAB_HOME, "uploads.log.jsonl");
12
+ const LOG_MAX_BYTES = 1 * 1024 * 1024; // 1 MB
13
+
14
+ function logUpload({ paths, method, outcome, origin, session }) {
15
+ const line =
16
+ JSON.stringify({
17
+ ts: new Date().toISOString(),
18
+ paths,
19
+ method,
20
+ outcome,
21
+ origin: origin || null,
22
+ session: session || null,
23
+ }) + "\n";
24
+
25
+ fs.mkdirSync(POLTERTAB_HOME, { recursive: true });
26
+ fs.appendFileSync(LOG_PATH, line, "utf8");
27
+
28
+ // Rotate if file exceeds limit
29
+ try {
30
+ const stat = fs.statSync(LOG_PATH);
31
+ if (stat.size > LOG_MAX_BYTES) {
32
+ let content = fs.readFileSync(LOG_PATH, "utf8");
33
+ const lines = content.split("\n").filter(Boolean);
34
+ while (lines.length > 1 && Buffer.byteLength(lines.join("\n") + "\n") > LOG_MAX_BYTES) {
35
+ lines.shift();
36
+ }
37
+ fs.writeFileSync(LOG_PATH, lines.join("\n") + "\n", "utf8");
38
+ }
39
+ } catch (_) {
40
+ // Rotation failure is non-fatal
41
+ }
42
+ }
43
+
44
+ function getRecentLog(n = 20) {
45
+ try {
46
+ const content = fs.readFileSync(LOG_PATH, "utf8");
47
+ const lines = content.split("\n").filter(Boolean);
48
+ return lines
49
+ .slice(-n)
50
+ .map((l) => {
51
+ try { return JSON.parse(l); } catch (_) { return null; }
52
+ })
53
+ .filter(Boolean);
54
+ } catch (_) {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ module.exports = { logUpload, getRecentLog, LOG_PATH, LOG_MAX_BYTES };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.9.0-beta.2",
3
+ "version": "1.10.0-beta.1",
4
4
  "description": "Let any AI assistant drive and scrape your existing Chrome profile — no headless browser, no complex setup.",
5
5
  "bin": {
6
6
  "poltertab": "./bin/poltertab.js"