@remcp/runtime 0.2.103 → 0.2.104

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.104 — 2026-09-22
4
+
5
+ - Fresh installations default the narrow catastrophic-command guardrail to block; ordinary development, file, Git, package-manager, Docker, database and browser workflows remain available, and explicit warn, allow, or unrestricted/godmode remain operator-controlled opt-ins.
6
+ - macOS top-level window handles include a snapshot generation and retain short-lived app/title/bounds identity so window reordering cannot silently retarget a previous id.
7
+ - A valid prior macOS handle resolves the same uniquely identifiable window after reorder; unknown, ambiguous, expired, PID-mismatched and legacy ids fail closed and request a fresh list_windows.
8
+ - Added 73 Desktop Commander prompt scenarios as an isolated compatibility smoke matrix; all 73 passed on the release tree with no guardrail false positives. GNOME Wayland live computer-use passed 80/80 checks.
9
+
3
10
  ## 0.2.103 — 2026-09-22
4
11
 
5
12
  - Native Wayland `window_action` targets exact PID/title Accessibility windows for `close` and verifies that the requested window disappeared.
package/README.md CHANGED
@@ -147,10 +147,10 @@ in `~/.config/remcp/runtime.json`. `--describe` always reports the current state
147
147
  against the resolved real path of the deepest existing ancestor rather than the lexical string, so
148
148
  `<allowed>/link -> /etc` cannot be used to read or write outside the allowed directories.
149
149
  `allowedRoots: ["/"]` means the whole filesystem and works as written.
150
- - **Command guardrail.** `dangerousCommands` defaults to `warn`: a catastrophic command still runs and
151
- the result carries a note. `allow` silences the note, `block` refuses before running. Rules match the
152
- *command word* of each shell segment, so `grep -n format README.md` is never affected. User
153
- `blockedCommands` entries are always enforced.
150
+ - **Command guardrail.** `dangerousCommands` defaults to `block`: a catastrophic command is refused
151
+ before execution. Operators can explicitly choose `warn` to run it with an advisory note or `allow`
152
+ to disable the built-in guardrail. Rules match the *command word* of each shell segment, so
153
+ `grep -n format README.md` is never affected. User `blockedCommands` entries are always enforced.
154
154
  - **A bad configuration is loud.** If `runtime.json` cannot be parsed, the device refuses to start and
155
155
  says why, instead of quietly dropping your `allowedRoots` and re-enabling usage metrics.
156
156
  - **Secret masking.** `list_processes` masks command arguments that look like tokens, passwords, or
@@ -182,7 +182,7 @@ Optional settings live in `~/.config/remcp/runtime.json` (override the directory
182
182
  "name": "workstation",
183
183
  "allowedRoots": ["~/projects", "/srv/data"],
184
184
  "blockedCommands": ["rm -rf /", "shutdown"],
185
- "dangerousCommands": "warn",
185
+ "dangerousCommands": "block",
186
186
  "telemetryEnabled": true,
187
187
  "maxOutputBytes": 1048576,
188
188
  "maxReadLines": 2000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.103",
3
+ "version": "0.2.104",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -61,10 +61,9 @@ function booleanValue(value, fallback) {
61
61
 
62
62
  const DANGEROUS_MODES = ['block', 'warn', 'allow'];
63
63
 
64
- // `warn` runs the command and adds a note when it matches the destructive-command list:
65
- // nothing is ever blocked or delayed, but a catastrophic command is visible in the tool
66
- // result. Set `allow` for zero noise or `block` to refuse.
67
- function dangerousMode(value, fallback = 'warn') {
64
+ // `block` is the secure default: matching catastrophic commands are refused before execution.
65
+ // Operators can explicitly choose `warn` to run with an advisory note or `allow` for zero guardrail noise.
66
+ function dangerousMode(value, fallback = 'block') {
68
67
  const normalized = String(value ?? '').trim().toLowerCase();
69
68
  return DANGEROUS_MODES.includes(normalized) ? normalized : fallback;
70
69
  }
@@ -23,6 +23,72 @@ function parseTsv(value, fields) {
23
23
  });
24
24
  }
25
25
 
26
+ const macWindowTargets = new Map();
27
+ const MAC_WINDOW_TARGET_TTL_MS = 5 * 60 * 1000;
28
+ let macWindowGeneration = 0;
29
+
30
+ function rememberMacWindowTarget(row) {
31
+ macWindowTargets.set(row.id, {
32
+ pid:row.pid,
33
+ app:row.app,
34
+ title:row.title,
35
+ x:row.x,
36
+ y:row.y,
37
+ width:row.width,
38
+ height:row.height,
39
+ at:Date.now(),
40
+ });
41
+ }
42
+
43
+ function cachedMacWindowTarget(id) {
44
+ const cached = macWindowTargets.get(id);
45
+ if (!cached) return null;
46
+ if ((Date.now() - cached.at) > MAC_WINDOW_TARGET_TTL_MS) {
47
+ macWindowTargets.delete(id);
48
+ return null;
49
+ }
50
+ return cached;
51
+ }
52
+
53
+ export function macWindowId(pid, windowIndex, generation) {
54
+ const numericPid = Number(pid);
55
+ const numericIndex = Number(windowIndex);
56
+ const numericGeneration = Number(generation);
57
+ if (!Number.isInteger(numericPid) || numericPid <= 0
58
+ || !Number.isInteger(numericIndex) || numericIndex < 0
59
+ || !Number.isInteger(numericGeneration) || numericGeneration <= 0) {
60
+ throw new Error('Could not construct a macOS window id from invalid PID/index/generation');
61
+ }
62
+ return `mac:${numericPid}:w${numericIndex}:g${numericGeneration}`;
63
+ }
64
+
65
+ export function normalizeMacWindowTarget(args = {}, { useWindowId = false } = {}) {
66
+ const explicitPid = Number.isInteger(Number(args.pid)) && Number(args.pid) > 0 ? Number(args.pid) : 0;
67
+ let idPid = 0;
68
+ let windowIndex = null;
69
+ const rawId = useWindowId ? optionalString(args.id) : '';
70
+ let cached = null;
71
+ if (rawId) {
72
+ const match = /^mac:(\d+):w(\d+):g(\d+)$/.exec(rawId);
73
+ if (!match) throw new Error('macOS window id is stale or unsupported; call list_windows again');
74
+ idPid = Number(match[1]);
75
+ windowIndex = Number(match[2]);
76
+ if (explicitPid && explicitPid !== idPid) throw new Error('macOS window id and pid refer to different processes');
77
+ cached = cachedMacWindowTarget(rawId);
78
+ }
79
+ return {
80
+ id:rawId,
81
+ idCached:Boolean(cached),
82
+ pid:idPid || explicitPid,
83
+ windowIndex,
84
+ app:optionalString(args.app) || '',
85
+ title:optionalString(args.window_title || args.windowTitle || args.title) || '',
86
+ expectedApp:cached?.app || '',
87
+ expectedTitle:cached?.title || '',
88
+ expectedBounds:cached ? [cached.x,cached.y,cached.width,cached.height] : null,
89
+ };
90
+ }
91
+
26
92
  export async function listWindows() {
27
93
  const script = `
28
94
  set output to ""
@@ -31,22 +97,30 @@ tell application "System Events"
31
97
  try
32
98
  set appName to name of p
33
99
  set appPid to unix id of p
100
+ set windowIndex to 0
34
101
  repeat with w in windows of p
35
102
  try
36
103
  set pos to position of w
37
104
  set sz to size of w
38
- set output to output & appPid & tab & appName & tab & (name of w) & tab & (item 1 of pos) & tab & (item 2 of pos) & tab & (item 1 of sz) & tab & (item 2 of sz) & linefeed
105
+ set output to output & appPid & tab & windowIndex & tab & appName & tab & (name of w) & tab & (item 1 of pos) & tab & (item 2 of pos) & tab & (item 1 of sz) & tab & (item 2 of sz) & linefeed
39
106
  end try
107
+ set windowIndex to windowIndex + 1
40
108
  end repeat
41
109
  end try
42
110
  end repeat
43
111
  end tell
44
112
  return output`;
45
113
  const { stdout } = await runOsa(script, { label: 'list windows' });
46
- const rows = parseTsv(stdout, ['pid','app','title','x','y','width','height']).map((row,index)=>({
47
- id:`mac:${row.pid}:${index}`,pid:Number(row.pid),app:row.app,title:row.title,
114
+ macWindowGeneration += 1;
115
+ const generation = macWindowGeneration;
116
+ const rows = parseTsv(stdout, ['pid','window_index','app','title','x','y','width','height']).map(row=>({
117
+ id:macWindowId(row.pid,row.window_index,generation),pid:Number(row.pid),app:row.app,title:row.title,
48
118
  x:Number(row.x),y:Number(row.y),width:Number(row.width),height:Number(row.height),
49
119
  }));
120
+ for (const [id,cached] of macWindowTargets.entries()) {
121
+ if ((Date.now() - cached.at) > MAC_WINDOW_TARGET_TTL_MS) macWindowTargets.delete(id);
122
+ }
123
+ for (const row of rows) rememberMacWindowTarget(row);
50
124
  return jsonResult(rows);
51
125
  }
52
126
 
@@ -67,7 +141,7 @@ export async function windowAction(args = {}) {
67
141
  if((action==='move'||action==='move_resize')&&(!Number.isFinite(x)||!Number.isFinite(y)))throw new Error('x and y are required for window move');
68
142
  if((action==='resize'||action==='move_resize')&&(!Number.isFinite(width)||!Number.isFinite(height)||width<=0||height<=0))throw new Error('width and height must be positive for window resize');
69
143
  const script=`(function(){
70
- ${macJxaTargetPrelude(args)}
144
+ ${macJxaTargetPrelude(args,{useWindowId:true})}
71
145
  function perform(e,wanted){let acts=[];try{acts=e.actions()}catch(_){};for(let i=0;i<acts.length;i++){let n='';try{n=String(acts[i].name())}catch(_){};if(n===wanted){acts[i].perform();return true}}return false}
72
146
  function setAttr(e,n,v){try{e.attributes.byName(n).value=v;return true}catch(_){return false}}
73
147
  const action=${JSON.stringify(action)};
@@ -86,21 +160,58 @@ return JSON.stringify({action,pid:Number(proc.unixId()),app:safeName(proc),windo
86
160
  let parsed;try{parsed=JSON.parse(stdout.trim())}catch{parsed={action,backend:'macos-accessibility'}}
87
161
  return jsonResult(parsed);
88
162
  }
89
- function macJxaTargetPrelude(args = {}) {
90
- const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : 0;
91
- const app = JSON.stringify(optionalString(args.app) || '');
92
- const title = JSON.stringify(optionalString(args.window_title || args.windowTitle || args.title) || '');
163
+ export async function resolveWindowTarget(args = {}) {
164
+ const script=`(function(){
165
+ ${macJxaTargetPrelude(args,{useWindowId:true})}
166
+ let p={};try{p=win.properties()}catch(_){};
167
+ return JSON.stringify({
168
+ id:${JSON.stringify(optionalString(args.id || args.window_id || args.windowId) || '')},
169
+ pid:Number(proc.unixId()),app:safeName(proc),title:safeName(win),
170
+ x:Array.isArray(p.position)?Number(p.position[0]):null,
171
+ y:Array.isArray(p.position)?Number(p.position[1]):null,
172
+ width:Array.isArray(p.size)?Number(p.size[0]):null,
173
+ height:Array.isArray(p.size)?Number(p.size[1]):null,
174
+ backend:'macos-accessibility'
175
+ });
176
+ })()`;
177
+ const {stdout}=await runOsa(script,{javascript:true,label:'resolve window target',timeout:30_000});
178
+ let parsed;try{parsed=JSON.parse(stdout.trim())}catch{throw new Error('Could not resolve macOS window target')}
179
+ return jsonResult(parsed);
180
+ }
181
+
182
+ function macJxaTargetPrelude(args = {}, options = {}) {
183
+ const target = normalizeMacWindowTarget(args, options);
184
+ if (options.useWindowId && target.id && !target.idCached) throw new Error('macOS window id is stale or unknown; call list_windows again');
185
+ const app = JSON.stringify(target.app);
186
+ const title = JSON.stringify(target.title);
187
+ const expectedApp = JSON.stringify(target.expectedApp);
188
+ const expectedTitle = JSON.stringify(target.expectedTitle);
189
+ const expectedBounds = JSON.stringify(target.expectedBounds);
93
190
  return String.raw`
94
191
  const se=Application('System Events');
95
- const wantPid=${pid};
192
+ const wantPid=${target.pid};
193
+ const wantWindowIndex=${target.windowIndex == null ? 'null' : target.windowIndex};
96
194
  const wantApp=${app};
97
195
  const wantTitle=${title};
196
+ const expectedApp=${expectedApp};
197
+ const expectedTitle=${expectedTitle};
198
+ const expectedBounds=${expectedBounds};
98
199
  function safeWindows(p){try{return p.windows()}catch(e){return []}}
99
200
  function safeName(spec){try{return String(spec.name()||'')}catch(e){return ''}}
201
+ function safeProps(spec){try{return spec.properties()}catch(e){return {}}}
202
+ function sameBounds(spec,bounds){
203
+ if(!bounds)return false;
204
+ const p=safeProps(spec),pos=Array.isArray(p.position)?p.position:[],size=Array.isArray(p.size)?p.size:[];
205
+ return Number(pos[0])===Number(bounds[0])&&Number(pos[1])===Number(bounds[1])&&Number(size[0])===Number(bounds[2])&&Number(size[1])===Number(bounds[3]);
206
+ }
100
207
  function chooseProcess(){
101
208
  if(wantPid){
102
209
  const rows=se.applicationProcesses.whose({unixId:wantPid})();
103
- if(rows.length)return rows[0];
210
+ if(rows.length){
211
+ if(wantApp && !safeName(rows[0]).toLowerCase().includes(wantApp.toLowerCase()))throw new Error('macOS window target app does not match the requested id/pid');
212
+ if(expectedApp && safeName(rows[0])!==expectedApp)throw new Error('macOS window id no longer belongs to the same application; call list_windows again');
213
+ return rows[0];
214
+ }
104
215
  throw new Error('No matching UI process found');
105
216
  }
106
217
  if(wantApp){
@@ -130,6 +241,30 @@ function chooseProcess(){
130
241
  function chooseWindow(p){
131
242
  const rows=safeWindows(p);
132
243
  if(!rows.length)throw new Error('The selected application has no accessible windows');
244
+ if(wantWindowIndex!==null){
245
+ const indexValid=Number.isInteger(wantWindowIndex)&&wantWindowIndex>=0&&wantWindowIndex<rows.length;
246
+ const selected=indexValid?rows[wantWindowIndex]:null;
247
+ if(expectedTitle){
248
+ if(selected&&safeName(selected)===expectedTitle)return selected;
249
+ const matches=[];
250
+ for(let i=0;i<rows.length;i++)if(safeName(rows[i])===expectedTitle)matches.push(rows[i]);
251
+ if(matches.length===1)return matches[0];
252
+ if(matches.length>1&&expectedBounds){
253
+ const bounded=matches.filter(row=>sameBounds(row,expectedBounds));
254
+ if(bounded.length===1)return bounded[0];
255
+ }
256
+ throw new Error('macOS window id no longer identifies one unambiguous window; call list_windows again');
257
+ }
258
+ if(expectedBounds){
259
+ if(selected&&sameBounds(selected,expectedBounds))return selected;
260
+ const bounded=rows.filter(row=>sameBounds(row,expectedBounds));
261
+ if(bounded.length===1)return bounded[0];
262
+ throw new Error('macOS window id no longer identifies one unambiguous window; call list_windows again');
263
+ }
264
+ if(!indexValid)throw new Error('macOS window id is stale; call list_windows again');
265
+ if(wantTitle && !safeName(selected).toLowerCase().includes(wantTitle.toLowerCase()))throw new Error('macOS window id no longer matches the requested title; call list_windows again');
266
+ return selected;
267
+ }
133
268
  if(wantTitle){
134
269
  for(let i=0;i<rows.length;i++){
135
270
  if(safeName(rows[i]).toLowerCase().includes(wantTitle.toLowerCase()))return rows[i];
@@ -355,7 +355,11 @@ export async function uiAction(args = {}) {
355
355
  if (['set_value','set_range_value'].includes(action) && resolved.value == null) throw new Error(`ui_action ${action} requires value`);
356
356
  return adapter.uiAction(resolved);
357
357
  }
358
- export async function keyboard(args = {}) { return adapter.keyboard(args); }
358
+ export async function keyboard(args = {}) {
359
+ const explicitWindowTarget = explicitTypeTextWindowTarget(args, false);
360
+ if (explicitWindowTarget) await windowAction({ action:'focus', ...explicitWindowTarget });
361
+ return adapter.keyboard(args);
362
+ }
359
363
  export async function pointer(args = {}) {
360
364
  if (args.action === 'move' && ![args.x,args.y].every(value => Number.isFinite(Number(value)))) throw new Error('pointer move requires x and y');
361
365
  return adapter.pointer(args);
@@ -373,13 +377,23 @@ export async function screenshotRegion(args = {}) {
373
377
  const title = optionalString(args.title);
374
378
  const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
375
379
  if (windowId || app || title || pid != null) {
376
- const rows = resultValue(await listWindows({ include_monitor:false }));
377
- const row = Array.isArray(rows) ? rows.find(item =>
378
- (!windowId || item.id === windowId)
379
- && (pid == null || Number(item.pid) === pid)
380
- && (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
381
- && (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
382
- ) : null;
380
+ let row = null;
381
+ if (windowId && typeof adapter.resolveWindowTarget === 'function') {
382
+ row = resultValue(await adapter.resolveWindowTarget({
383
+ id:windowId,
384
+ ...(pid != null ? { pid } : {}),
385
+ ...(app ? { app } : {}),
386
+ ...(title ? { title } : {}),
387
+ }));
388
+ } else {
389
+ const rows = resultValue(await listWindows({ include_monitor:false }));
390
+ row = Array.isArray(rows) ? rows.find(item =>
391
+ (!windowId || item.id === windowId)
392
+ && (pid == null || Number(item.pid) === pid)
393
+ && (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
394
+ && (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
395
+ ) : null;
396
+ }
383
397
  if (!row) throw new Error('No matching window found for screenshot_region');
384
398
  x = Number(row.x); y = Number(row.y); width = Number(row.width); height = Number(row.height);
385
399
  } else if (args.monitor != null || args.monitor_index != null) {
package/src/policy.mjs CHANGED
@@ -2,10 +2,10 @@ import { runtimeConfig } from './config.mjs';
2
2
  import { recordEvent } from './telemetry.mjs';
3
3
  import { fail } from './util.mjs';
4
4
 
5
- // Optional hardening rules for catastrophic host-level commands. The default is `allow`:
6
- // ReMCP is a remote-control tool for computers you own, and the agent needs to be able to
7
- // do anything you could do at a shell. Deployments that want a safety net can set
8
- // `dangerousCommands` to `warn` (run and report) or `block` (refuse before running).
5
+ // Built-in hardening rules for catastrophic host-level commands. The default is `block`:
6
+ // fresh installations refuse these commands before execution. Operators can explicitly choose
7
+ // `warn` (run and report) or `allow` (disable this built-in guardrail); unrestricted/godmode
8
+ // also forces `allow` and remains a local-only opt-in.
9
9
  //
10
10
  // Rules are matched against the *command word* of each shell segment, so a read-only
11
11
  // command that merely mentions a dangerous word (`grep -n format README.md`,