@rjfan/dsh-presence 0.18.0 → 0.20.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.
@@ -8,6 +8,16 @@
8
8
  // `git clean` -> dry run) so nothing is removed,
9
9
  // 3. refuses unset variables, workspace roots/ancestors, home, `/` and `.git`,
10
10
  // 4. otherwise returns a report of the resolved targets and requires confirm.
11
+ //
12
+ // 0.19.0 adds two things the P0 feedback asked for:
13
+ // 5. a top-level split of the command line (`;`, `&&`, `||`, newlines outside
14
+ // quotes), so a rejected mixed command can list the segments that are safe
15
+ // to run on their own (`confirm:"partial"`), and
16
+ // 6. a single-file exemption: exactly `rm -f <literal path>` for a path that
17
+ // resolves inside the session workspace runs directly — it cannot remove a
18
+ // tree, which is what the guard exists to stop.
19
+
20
+ import { posix } from 'node:path';
11
21
 
12
22
  export function shq(value) {
13
23
  return `'${String(value).replaceAll("'", "'\\''")}'`;
@@ -66,6 +76,176 @@ export function shellWords(text) {
66
76
  return words;
67
77
  }
68
78
 
79
+ /**
80
+ * Split one command line on top-level `;`, `&&`, `||` and newlines.
81
+ *
82
+ * Quotes (`'`, `"`, backtick), backslash escapes and `(...)` / `$(...)` nesting
83
+ * are honoured, so `echo "a; b"` and `echo "$(a; b)"` stay one segment. A lone
84
+ * `&` or `|` is a background/pipeline operator rather than a sequence boundary,
85
+ * so it stays inside its segment; that keeps the unit conservative (the whole
86
+ * pipeline is one destructive or one safe segment).
87
+ *
88
+ * @returns {string[]} trimmed, non-empty segments in command order.
89
+ */
90
+ export function splitShellSegments(command) {
91
+ const text = String(command ?? '');
92
+ const segments = [];
93
+ let current = '';
94
+ let quote = null;
95
+ let depth = 0;
96
+ const flush = () => {
97
+ const segment = current.trim();
98
+ if (segment) segments.push(segment);
99
+ current = '';
100
+ };
101
+ for (let index = 0; index < text.length; index += 1) {
102
+ const character = text[index];
103
+ if (quote === "'") {
104
+ current += character;
105
+ if (character === "'") quote = null;
106
+ continue;
107
+ }
108
+ if (quote === '"' || quote === '`') {
109
+ current += character;
110
+ if (character === '\\' && index + 1 < text.length) {
111
+ current += text[index + 1];
112
+ index += 1;
113
+ } else if (character === quote) {
114
+ quote = null;
115
+ }
116
+ continue;
117
+ }
118
+ if (character === '\\' && index + 1 < text.length) {
119
+ // An escaped operator is a literal character, not a boundary.
120
+ current += character + text[index + 1];
121
+ index += 1;
122
+ continue;
123
+ }
124
+ if (character === "'" || character === '"' || character === '`') {
125
+ quote = character;
126
+ current += character;
127
+ continue;
128
+ }
129
+ if (character === '(') {
130
+ depth += 1;
131
+ current += character;
132
+ continue;
133
+ }
134
+ if (character === ')') {
135
+ if (depth > 0) depth -= 1;
136
+ current += character;
137
+ continue;
138
+ }
139
+ if (depth === 0) {
140
+ if (character === ';' || character === '\n' || character === '\r') {
141
+ flush();
142
+ continue;
143
+ }
144
+ if (character === '&' && text[index + 1] === '&') {
145
+ flush();
146
+ index += 1;
147
+ continue;
148
+ }
149
+ if (character === '|' && text[index + 1] === '|') {
150
+ flush();
151
+ index += 1;
152
+ continue;
153
+ }
154
+ }
155
+ current += character;
156
+ }
157
+ flush();
158
+ return segments;
159
+ }
160
+
161
+ /** Characters that make a path non-literal (glob, expansion, redirect, chaining). */
162
+ const NOT_LITERAL_PATH = /[*?[\]$;&|<>`(){}\\~\n\r]/;
163
+
164
+ /**
165
+ * The path of an exact `rm -f <one literal path>` command, or null.
166
+ *
167
+ * The exemption is intentionally narrow: a different verb/mount, `-r`, an
168
+ * extra operand, a glob, a variable, a redirect, a pipeline or a second
169
+ * command all keep the normal guard path.
170
+ */
171
+ export function singleRmTarget(command) {
172
+ const text = String(command ?? '');
173
+ if (NOT_LITERAL_PATH.test(text)) return null;
174
+ const words = shellWords(text);
175
+ if (words.length !== 3) return null;
176
+ if (words[0] !== 'rm' || words[1] !== '-f') return null;
177
+ const target = words[2];
178
+ if (!target || target.startsWith('-')) return null;
179
+ return target;
180
+ }
181
+
182
+ function withoutTrailingSlashes(value) {
183
+ const text = String(value);
184
+ if (text === '/') return '/';
185
+ return text.replace(/\/+$/, '');
186
+ }
187
+
188
+ /** Absolute, lexically normalised form of `target` relative to `cwd`. */
189
+ function absoluteRemotePath(cwd, target) {
190
+ if (!cwd || !target) return null;
191
+ try {
192
+ return posix.normalize(posix.join(String(cwd), String(target)));
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * True when `target` is deletable under the exemption: inside the workspace,
200
+ * but neither the workspace root itself nor an ancestor of it.
201
+ */
202
+ export function isDeletableWorkspaceFile(target, workspaceRoot) {
203
+ const path = withoutTrailingSlashes(target);
204
+ const base = withoutTrailingSlashes(workspaceRoot);
205
+ if (!path.startsWith('/') || !base.startsWith('/')) return false;
206
+ if (path === base) return false;
207
+ // `path` is an ancestor of the workspace root (e.g. `/tmp` for `/tmp/ws`).
208
+ if (base.startsWith(`${path}/`)) return false;
209
+ return path.startsWith(`${base}/`);
210
+ }
211
+
212
+ /** Resolve a remote path through symlinks; falls back to the parent directory. */
213
+ async function resolveRemotePath(connection, target, cwd) {
214
+ const script =
215
+ `cd ${shq(cwd)} || exit 0; p=${shq(target)}; ` +
216
+ `r=$(realpath -m -- "$p" 2>/dev/null || readlink -f -- "$p" 2>/dev/null); ` +
217
+ `[ -n "$r" ] || r=$(cd "$(dirname -- "$p")" 2>/dev/null && printf '%s/%s' "$PWD" "$(basename -- "$p")"); ` +
218
+ `printf '%s\\n' "$r"`;
219
+ try {
220
+ const result = await connection.exec(`bash -c ${shq(script)}`, { timeoutMs: 15_000 });
221
+ const line = String(result.stdout ?? '')
222
+ .split('\n')
223
+ .map((value) => value.trim())
224
+ .filter(Boolean)[0];
225
+ return line ?? null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * The 0.19.0 single-file exemption: `rm -f <one literal path>` for a path that
233
+ * resolves inside the workspace, is not the workspace root and cannot be an
234
+ * ancestor of it. Symlinks are resolved first, so a link inside the workspace
235
+ * that points outside is not exempt.
236
+ */
237
+ async function exemptSingleFileRm({ connection, command, cwd, workspace }) {
238
+ const workspaceRoot = workspace?.path ?? null;
239
+ if (!cwd || !workspaceRoot) return false;
240
+ const target = singleRmTarget(command);
241
+ if (!target) return false;
242
+ const lexical = absoluteRemotePath(cwd, target);
243
+ if (!lexical) return false;
244
+ const resolved = await resolveRemotePath(connection, target, cwd);
245
+ const candidate = resolved && resolved.startsWith('/') ? posix.normalize(resolved) : lexical;
246
+ return isDeletableWorkspaceFile(candidate, workspaceRoot);
247
+ }
248
+
69
249
  function isWithin(parent, child) {
70
250
  if (!parent) return false;
71
251
  const base = parent.replace(/\/+$/, '');
@@ -74,6 +254,13 @@ function isWithin(parent, child) {
74
254
 
75
255
  /** Run the command in a shell where the destructive verb only reports. */
76
256
  async function dryRunDestructive(connection, command, kind, cwd) {
257
+ if (kind === 'truncate') {
258
+ // There is no safe replay for these verbs: `shred`/`truncate`/`mkfs`/`dd of=`
259
+ // write for real and a `:>` redirect cannot be shadowed. Never execute them
260
+ // in the dry run; report that targets cannot be resolved instead of doing
261
+ // the damage while claiming "nothing was executed".
262
+ return { targets: [], stderr: '', code: 0, skipped: true };
263
+ }
77
264
  let probe;
78
265
  if (kind === 'rm') {
79
266
  probe =
@@ -136,27 +323,71 @@ async function describeTargets(connection, targets, cwd) {
136
323
  });
137
324
  }
138
325
 
326
+ /**
327
+ * Classify every top-level segment so a rejected command can say which parts
328
+ * were safe. `targets` is best effort: a segment that depends on an earlier
329
+ * assignment (`J=""; rm -rf $J`) cannot be dry-run on its own, so it stays empty
330
+ * instead of guessing.
331
+ */
332
+ async function describeSplit({ connection, command, cwd, kind, resolved }) {
333
+ const safeSegments = [];
334
+ const destructiveSegments = [];
335
+ for (const segment of splitShellSegments(command)) {
336
+ const segmentKind = destructiveKind(segment);
337
+ if (segmentKind) destructiveSegments.push({ segment, kind: segmentKind, targets: [] });
338
+ else safeSegments.push(segment);
339
+ }
340
+ if (destructiveSegments.length === 0) {
341
+ // The classifier matched across a line break the splitter treats as a
342
+ // boundary; keep the whole command as the single destructive unit.
343
+ destructiveSegments.push({ segment: String(command).trim(), kind, targets: [] });
344
+ }
345
+ if (destructiveSegments.length === 1 && safeSegments.length === 0) {
346
+ // The whole command is the destructive segment: reuse the targets the
347
+ // guard already resolved instead of dry-running it a second time.
348
+ destructiveSegments[0].targets = resolved;
349
+ return { safeSegments, destructiveSegments };
350
+ }
351
+ for (const entry of destructiveSegments) {
352
+ try {
353
+ const segmentDry = await dryRunDestructive(connection, entry.segment, entry.kind, cwd);
354
+ entry.targets = await describeTargets(connection, segmentDry.targets, cwd);
355
+ } catch {
356
+ entry.targets = [];
357
+ }
358
+ }
359
+ return { safeSegments, destructiveSegments };
360
+ }
361
+
139
362
  /**
140
363
  * Gate one destructive command.
141
364
  *
142
- * Returns null for a benign command (= run it), a report object for a destructive
143
- * command without confirmation (= do not run, show the report), and throws for
144
- * the unrecoverable cases (unset variable, workspace root/ancestor, home, `/`,
145
- * `.git`) regardless of confirmation.
365
+ * Returns null for a benign command (= run it) or for the single-file `rm -f`
366
+ * exemption, a report object for a destructive command without confirmation
367
+ * (= do not run, show the report), and throws for the unrecoverable cases
368
+ * (unset variable, workspace root/ancestor, home, `/`, `.git`) regardless of
369
+ * confirmation.
146
370
  *
147
371
  * @param connection - live PresenceConnection.
148
372
  * @param command - the raw command line.
149
373
  * @param cwd - remote working directory, or null.
150
374
  * @param workspace - `{ path }` of the session workspace, or null.
151
375
  * @param host - alias used in messages.
152
- * @param confirm - the caller already saw a report and asked to proceed.
376
+ * @param confirm - the caller already saw a report and asked to proceed
377
+ * (`true` = whole command, `'partial'` = the caller runs split.safeSegments).
153
378
  */
154
379
  export async function guardDestructive({ connection, command, cwd = null, workspace = null, host = 'remote', confirm = false }) {
155
380
  const kind = destructiveKind(command);
156
381
  if (!kind) return null;
157
382
 
383
+ // 0.19.0: removing one generated file with `rm -f` is the everyday case; the
384
+ // guard exists to stop tree deletions. Everything that is not exactly
385
+ // `rm -f <literal path>` inside the workspace keeps the full guard below.
386
+ if (await exemptSingleFileRm({ connection, command, cwd, workspace })) return null;
387
+
158
388
  const dry = await dryRunDestructive(connection, command, kind, cwd);
159
389
  const resolved = await describeTargets(connection, dry.targets, cwd);
390
+ const split = await describeSplit({ connection, command, cwd, kind, resolved });
160
391
  const home = await connection.homePath().catch(() => null);
161
392
  const workspaceRoot = workspace?.path ?? null;
162
393
 
@@ -174,20 +405,42 @@ export async function guardDestructive({ connection, command, cwd = null, worksp
174
405
  );
175
406
  }
176
407
 
177
- const report = [
408
+ const targetsLine =
409
+ resolved.length > 0
410
+ ? 'resolved targets:'
411
+ : dry.skipped
412
+ ? `resolved targets: (not resolvable without doing it - ${kind} commands are never replayed)`
413
+ : 'resolved targets: (none - the command would touch nothing)';
414
+ const safeLines = split.safeSegments.length > 0 ? split.safeSegments.map((segment) => ` ${segment}`) : [' (none)'];
415
+ const confirmHint =
416
+ split.safeSegments.length > 0
417
+ ? 'Pass confirm:true to run the whole command, confirm:"partial" to run only the safe segments, ' +
418
+ 'or use presence_trash for a recoverable delete.'
419
+ : 'Pass confirm:true to run the whole command, or use presence_trash for a recoverable delete.';
420
+
421
+ const lines = [
422
+ 'Nothing was executed — the whole command was skipped, including non-destructive parts.',
178
423
  `destructive command needs confirmation (${kind}): nothing was executed yet`,
179
424
  `host: ${host}`,
180
425
  `command: ${String(command).replace(/\s+/g, ' ').slice(0, 400)}`,
181
426
  cwd ? `cwd: ${cwd}` : null,
182
- resolved.length > 0 ? 'resolved targets:' : 'resolved targets: (none - the command would touch nothing)',
427
+ targetsLine,
183
428
  ...resolved.map((entry) => ` ${entry.type} ${entry.path} ${entry.entries} ${entry.size}`),
184
- confirm
185
- ? null
186
- : 'Pass confirm:true to execute it. Prefer presence_trash when the files are in a workspace (recoverable).',
187
- ].filter(Boolean);
429
+ '',
430
+ 'Safe segments you can run separately:',
431
+ ...safeLines,
432
+ ];
433
+ if (!confirm) lines.push('', confirmHint);
188
434
 
189
435
  // `targets` is the machine-readable twin of the report's target block:
190
436
  // callers that answer in JSON (presence_exec format:'json') do not need to
191
437
  // parse the text, and existing callers keep reading `report`/`resolved`.
192
- return { kind, report: report.join('\n'), resolved, targets: resolved };
438
+ // `split` lets the caller run only the safe segments (confirm:"partial").
439
+ return {
440
+ kind,
441
+ report: lines.filter((line) => line !== null && line !== undefined).join('\n'),
442
+ resolved,
443
+ targets: resolved,
444
+ split,
445
+ };
193
446
  }
package/src/intercept.js CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { parseMountPath, parsePresenceUrl, resolveRemoteInput } from './mounts.js';
10
10
  import { remoteGlobDetailed, remoteGrep, splitGlobPattern } from './remote-search.js';
11
+ import { canonicalObservation, lookupObservation, recordObservation } from './observed-store.js';
11
12
 
12
13
  const LIMIT = 200;
13
14
 
@@ -131,3 +132,152 @@ export function installToolInterceptor(ctx, { pool, cwdOf }) {
131
132
  }
132
133
  });
133
134
  }
135
+
136
+ /**
137
+ * Bridge between the Presence file tools and the harness read-before-edit
138
+ * policy (plugin `fs-observation-policy`).
139
+ *
140
+ * The policy records an authoritative observation only when a tool emits
141
+ * `fs/observed` with a resolved target and the returned version. Its state is a
142
+ * WeakMap keyed by the live Session object inside that plugin — it is not a
143
+ * service, so it cannot be read or written directly from here. What IS possible
144
+ * (and what this bridge does) is to publish the same event the built-in tools
145
+ * publish:
146
+ * - after `presence_read`/`presence_write`/`presence_edit` succeed (through the
147
+ * `observe` callback the tools call), so a later built-in `edit` on the same
148
+ * remote file passes;
149
+ * - after a built-in `read`/`read_image`, so the persisted record survives
150
+ * compaction (the guard's own WeakMap does not);
151
+ * - just before a built-in `edit`/`write`, re-publishing the recorded version
152
+ * when the file is still at exactly that version. A changed file is never
153
+ * auto-approved.
154
+ *
155
+ * `exec cat` (or any command that happens to read a file) is deliberately NOT
156
+ * turned into an observation: a shell command carries neither a target identity
157
+ * nor a version, and fabricating one would defeat the guard.
158
+ *
159
+ * @param ctx - plugin context (event owner).
160
+ * @param options.cwdOf - async (exec) => session cwd or null.
161
+ * @param options.late - late-bound services (`fs`).
162
+ * @returns `{ observe, resolveObservation }`; `observe` is the callback
163
+ * `definePresenceTools` expects, `resolveObservation` is useful for probes.
164
+ */
165
+ export function installObservationBridge(ctx, { cwdOf, late }) {
166
+ const sessionIdOf = (exec) => exec?.agent?.session?.id ?? exec?.agent?.session?.header?.id ?? null;
167
+
168
+ const explicitHostOf = (exec) => {
169
+ const host = exec?.arguments?.host;
170
+ return typeof host === 'string' && host.trim() ? host.trim() : null;
171
+ };
172
+
173
+ /**
174
+ * Resolve a tool path spelling to `{ target, canonical }`. A remote spelling
175
+ * becomes the canonical `presence://host/path` target directly (so a local
176
+ * session that passed `host` explicitly still records the right identity);
177
+ * a genuinely local path falls back to the provider's own target, keeping the
178
+ * pre-0.20 local repair behavior.
179
+ */
180
+ const resolveObservation = async (exec, path) => {
181
+ if (typeof path !== 'string' || path === '') return null;
182
+ let cwd = null;
183
+ try {
184
+ cwd = (await cwdOf(exec)) ?? null;
185
+ } catch {
186
+ cwd = null;
187
+ }
188
+ const explicitHost = explicitHostOf(exec);
189
+ const canonical = canonicalObservation(path, { cwd, host: explicitHost });
190
+ if (canonical) {
191
+ // The presence provider's identity IS the presence URL; the policy only
192
+ // reads `targetKey`, and every other `fs/observed` listener can stat this
193
+ // target through the same provider.
194
+ return { target: { targetKey: canonical.key, displayPath: canonical.key }, canonical };
195
+ }
196
+ // A relative spelling cannot be mapped when the call named a different host
197
+ // than the mounted workspace: resolving it through the provider would record
198
+ // the mounted machine's file, which is NOT the file the tool touched. Never
199
+ // fabricate a target that belongs to another machine.
200
+ if (explicitHost && cwd && parseMountPath(cwd)) return null;
201
+ const fs = late?.fs;
202
+ if (!fs || typeof fs.resolve !== 'function') return null;
203
+ try {
204
+ const target = await fs.resolve(path, cwd ? { cwd } : undefined);
205
+ if (!target) return null;
206
+ return { target, canonical: canonicalObservation(target, { cwd }) };
207
+ } catch {
208
+ return null;
209
+ }
210
+ };
211
+
212
+ /** Record that this session saw the file at its current version. */
213
+ const observe = async (exec, path, options = {}) => {
214
+ try {
215
+ const resolved = await resolveObservation(exec, path);
216
+ if (!resolved) return;
217
+ const fs = late?.fs;
218
+ const stat = fs && typeof fs.stat === 'function' ? await fs.stat(resolved.target) : null;
219
+ const observation = stat ? { kind: 'present', version: stat.version } : { kind: 'absent' };
220
+ // The built-in read/write tools already publish their own event; a second
221
+ // one would only churn `changes` listeners. Presence tools have no other
222
+ // publisher, so they always publish here.
223
+ if (options.publish !== false) ctx.emit('fs/observed', resolved.target, observation, { agent: exec?.agent });
224
+ if (stat && options.remember !== false) {
225
+ recordObservation(
226
+ sessionIdOf(exec),
227
+ resolved.canonical ?? String(resolved.target.targetKey ?? ''),
228
+ stat.version,
229
+ { cwd: null },
230
+ );
231
+ }
232
+ } catch {
233
+ /* the observation is an optimization, never a failure */
234
+ }
235
+ };
236
+
237
+ /** Re-publish a recorded observation if the file is still at that version. */
238
+ const repair = async (exec, path) => {
239
+ const sessionId = sessionIdOf(exec);
240
+ if (!sessionId) return null;
241
+ const resolved = await resolveObservation(exec, path);
242
+ if (!resolved) return null;
243
+ const targetKey = resolved.canonical?.key ?? String(resolved.target.targetKey ?? '');
244
+ const recorded = lookupObservation(sessionId, resolved.canonical ?? targetKey);
245
+ if (!recorded) return null;
246
+ const fs = late?.fs;
247
+ const stat = fs && typeof fs.stat === 'function' ? await fs.stat(resolved.target) : null;
248
+ if (!stat || stat.version !== recorded) return null;
249
+ ctx.emit('fs/observed', resolved.target, { kind: 'present', version: stat.version }, { agent: exec.agent });
250
+ return stat.version;
251
+ };
252
+
253
+ // Built-in read/read_image: remember what the session has seen, so the record
254
+ // survives compaction even though the guard's WeakMap does not.
255
+ ctx.on('tools/post-execute', async (exec, result, next) => {
256
+ try {
257
+ if (!result?.isError && (exec?.name === 'read' || exec?.name === 'read_image')) {
258
+ await observe(exec, exec.arguments?.file_path, { publish: false });
259
+ }
260
+ } catch {
261
+ /* best effort */
262
+ }
263
+ return next();
264
+ });
265
+
266
+ // Built-in edit/write on a remote target: if this session read the file at
267
+ // the same version we recorded, re-publish the observation so the guard
268
+ // passes. A changed file is left alone: the guard's refusal is the point.
269
+ ctx.on('tools/pre-execute', async (exec, next) => {
270
+ try {
271
+ if (exec?.name === 'edit' || exec?.name === 'write') {
272
+ const path = exec.arguments?.file_path;
273
+ if (typeof path === 'string' && path !== '') await repair(exec, path);
274
+ }
275
+ } catch {
276
+ /* best effort: a failed repair leaves the original guard behavior */
277
+ }
278
+ return next();
279
+ });
280
+
281
+ return { observe, resolveObservation };
282
+ }
283
+