poltertab 1.9.0 → 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 +11 -0
- package/bin/uploads.js +238 -0
- package/mcp-server/bridge.js +198 -1
- package/mcp-server/index.js +10 -1
- package/mcp-server/upload-paths.js +74 -4
- package/mcp-server/uploads-config.js +80 -0
- package/mcp-server/uploads-log.js +59 -0
- package/package.json +1 -1
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 };
|
package/mcp-server/bridge.js
CHANGED
|
@@ -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();
|
package/mcp-server/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
74
|
-
"
|
|
75
|
-
"Example:\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
|
-
|
|
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