@1agh/maude 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/build.ts +28 -1
  7. package/apps/studio/client/app.jsx +47 -30
  8. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  9. package/apps/studio/client/panels/CloudBar.jsx +72 -13
  10. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  11. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  12. package/apps/studio/client/panels/acp-runtime.js +96 -5
  13. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  14. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  15. package/apps/studio/dist/client.bundle.js +1278 -1278
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/http.ts +51 -2
  18. package/apps/studio/server.ts +11 -0
  19. package/apps/studio/sync/connection-state.ts +116 -6
  20. package/apps/studio/sync/index.ts +50 -1
  21. package/apps/studio/sync/presentation.ts +273 -0
  22. package/apps/studio/sync/remote-docs.ts +191 -0
  23. package/apps/studio/sync/status.ts +4 -1
  24. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  25. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  26. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  27. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  28. package/apps/studio/test/acp-commands.test.ts +5 -0
  29. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  30. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  31. package/apps/studio/test/acp-permission.test.ts +18 -2
  32. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  33. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  34. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  35. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  36. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  37. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  38. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  39. package/apps/studio/test/cloud-endpoints.test.ts +11 -4
  40. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  41. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  42. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  43. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  44. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  45. package/apps/studio/test/sync-presentation.test.ts +185 -0
  46. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  47. package/apps/studio/whats-new.json +18 -0
  48. package/package.json +8 -8
@@ -21,6 +21,11 @@ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-
21
21
  // canvas-cursors.ts above.
22
22
  import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
23
23
  import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
24
+ // The single "what is the hub link doing" rule, shared with the cloud rail's
25
+ // connect note so the two surfaces can never disagree. Pure data + strings —
26
+ // same "pull only pure logic into the client bundle" shape as the imports
27
+ // above (a type-only SyncStatusSnapshot import that Bun erases).
28
+ import { syncPresentation } from '../sync/presentation.ts';
24
29
  import { canvasUrl } from './canvas-url.js';
25
30
  import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
26
31
  import { useTreeDrag } from './use-tree-drag.js';
@@ -77,6 +82,7 @@ const Lu = ({ as: C, size = 14 }) => <C size={size} strokeWidth={1.75} style={{
77
82
  import { PhotoKnobs } from './photo-knobs.jsx';
78
83
  import {
79
84
  appIsFirstRun,
85
+ invoke,
80
86
  isNativeApp,
81
87
  onMenuReportBug,
82
88
  onUpdateReady,
@@ -203,14 +209,30 @@ const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__
203
209
  // handleAssistantAttention below (identical support/permission check +
204
210
  // try/catch, only the title/body differ). The in-app badge stays the
205
211
  // reliable signal if this silently fails for any reason.
212
+ //
213
+ // feature-acp-turn-notifications Task 6 — under Tauri, route through the
214
+ // native `send_notification` command (notify.rs) instead of the Web `Notification` API.
215
+ // This is the SAME native notifier the shell's cross-project poller uses for
216
+ // every OTHER project, so the visible project now goes through the identical
217
+ // OS-level path rather than a webview API whose behavior inside WKWebView was
218
+ // never empirically confirmed (see Task 1's finding in the plan). Falls back
219
+ // to the Web API on any failure — an older desktop build without the command,
220
+ // or a plain browser tab — so the signal degrades rather than disappearing.
206
221
  function notifyDesktop(title, body) {
207
- try {
208
- if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
209
- new Notification(title, { body });
222
+ const webFallback = () => {
223
+ try {
224
+ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
225
+ new Notification(title, { body });
226
+ }
227
+ } catch {
228
+ /* best-effort — the in-app badge is the reliable signal */
210
229
  }
211
- } catch {
212
- /* best-effort — the in-app badge is the reliable signal */
230
+ };
231
+ if (isNativeApp()) {
232
+ invoke('send_notification', { title, body }).catch(webFallback);
233
+ return;
213
234
  }
235
+ webFallback();
214
236
  }
215
237
 
216
238
  function readInitialTheme() {
@@ -2473,6 +2495,9 @@ function Sidebar({
2473
2495
  onMoveCanvas,
2474
2496
  onNewFolder,
2475
2497
  onDeleteFolder,
2498
+ // Passed through to CloudBar only — the live `sync:status` payload that makes
2499
+ // the connect note follow the link instead of freezing at attach time.
2500
+ syncStatus,
2476
2501
  }) {
2477
2502
  const filteredGroups = useMemo(() => {
2478
2503
  if (!search) return groups;
@@ -2846,7 +2871,7 @@ function Sidebar({
2846
2871
  Absent rather than disabled, because unlike the agent chat there is
2847
2872
  nothing here to explain — the capability is not missing, it is
2848
2873
  already satisfied. */}
2849
- {cloud === null ? <CloudBar /> : null}
2874
+ {cloud === null ? <CloudBar syncStatus={syncStatus} /> : null}
2850
2875
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
2851
2876
  sign in, connected account + New/Pull/Share, sign out. Self-contained
2852
2877
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -4537,6 +4562,7 @@ function StatusBar({
4537
4562
  onToggleTheme,
4538
4563
  onClearSelected,
4539
4564
  syncStatus,
4565
+ syncProject,
4540
4566
  changesCount = 0,
4541
4567
  unpushed = 0,
4542
4568
  changesOpen = false,
@@ -4559,32 +4585,21 @@ function StatusBar({
4559
4585
  // shapes: solo `{linked:false}` (hide), DDR-060 `{notSyncable,tsxCount,reason}`
4560
4586
  // (linked but 0 syncable), or the connection-state machine `{state,queuedOps,
4561
4587
  // flash,…}` (the common linked case the old notSyncable-only guard never
4562
- // showed — the "hub sync se neukazuje" bug). Map each to a label + dot tone.
4588
+ // showed — the "hub sync se neukazuje" bug).
4589
+ //
4590
+ // The mapping used to live here, and it referenced `docs` ZERO times: it read
4591
+ // `state` alone, so a link whose every document the hub had refused still
4592
+ // showed a green dot and the word "synced". `syncPresentation` is now the one
4593
+ // rule, shared with the cloud rail's connect note, so the two surfaces cannot
4594
+ // give a person different answers about the same payload.
4563
4595
  const syncSlot = (() => {
4564
- if (!syncStatus || syncStatus.linked === false) return null;
4565
- if (syncStatus.notSyncable) {
4566
- return {
4567
- online: false,
4568
- label: `0 syncable${syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}`,
4569
- title: syncStatus.reason || 'Linked to a hub, but no canvases are syncable.',
4570
- };
4571
- }
4572
- const q = syncStatus.queuedOps ?? 0;
4573
- const synced = syncStatus.state === 'online' || syncStatus.flash === 'synced';
4574
- if (synced) {
4575
- return {
4576
- online: true,
4577
- label: q > 0 ? `${q} ↑` : 'synced',
4578
- title: q > 0 ? `${q} edit(s) queued to push` : 'All changes synced to the hub',
4579
- };
4580
- }
4596
+ const p = syncPresentation(syncStatus, { project: syncProject });
4597
+ if (!p) return null;
4598
+ const detail = p.names.length ? ` (${p.names.join(', ')})` : '';
4581
4599
  return {
4582
- online: false,
4583
- label: `${q} ↑`,
4584
- title:
4585
- syncStatus.state === 'connecting'
4586
- ? 'Connecting to the hub…'
4587
- : 'Offline — edits queued, will sync when the hub reconnects',
4600
+ online: p.online,
4601
+ label: p.label,
4602
+ title: `${p.title}${detail}${p.next ? ` — ${p.next}` : ''}`,
4588
4603
  };
4589
4604
  })();
4590
4605
 
@@ -14087,6 +14102,7 @@ function App() {
14087
14102
  remoteSync={remoteSync}
14088
14103
  onGetLatest={gitGetLatest}
14089
14104
  canvasKinds={cfg?.canvasKinds}
14105
+ syncStatus={syncStatus}
14090
14106
  />
14091
14107
  );
14092
14108
  if (id === 'changes')
@@ -15147,6 +15163,7 @@ function App() {
15147
15163
  onToggleTheme={toggleTheme}
15148
15164
  onClearSelected={clearSelected}
15149
15165
  syncStatus={syncStatus}
15166
+ syncProject={cfg?.cloud?.projectName || project}
15150
15167
  changesCount={unsavedCount}
15151
15168
  unpushed={gitStatus?.unpushed || 0}
15152
15169
  changesOpen={changesOpen}
@@ -1855,6 +1855,11 @@ export default function ChatPanel({
1855
1855
  const connsRef = useRef(new Map()); // chatId → connection
1856
1856
  const busyRef = useRef(new Map()); // chatId → busy (aggregated for the menubar)
1857
1857
  const hydratedRef = useRef(new Map()); // chatId → initial messages
1858
+ // chatId → the transcript LINE the hydration above covered (the
1859
+ // `X-Maude-Chat-Seq` response header). Handed to the connection's `attach`
1860
+ // so the server replays only what the client missed — Addendum Task 8's
1861
+ // re-attach seam. Absent/0 for a chat that was never hydrated (a new chat).
1862
+ const hydratedSeqRef = useRef(new Map());
1858
1863
  const [openChatIds, setOpenChatIds] = useState([]);
1859
1864
  const [activeChatId, setActiveChatId] = useState(null);
1860
1865
  const [busyChats, setBusyChats] = useState({}); // reactive: chatId → busy (for the dot)
@@ -1872,6 +1877,13 @@ export default function ChatPanel({
1872
1877
  const existing = connsRef.current.get(chatId);
1873
1878
  if (existing) return existing;
1874
1879
  const conn = createAcpConnection();
1880
+ // Addendum Task 8 — bind BEFORE anything can open the socket. The `attach`
1881
+ // frame goes out from `onopen` and is what subscribes this socket to the
1882
+ // chat's server-side bridge (which now outlives its socket, so it may
1883
+ // already be mid-turn from before a reload). `hydratedSeqRef` holds the
1884
+ // transcript line the history fetch covered, so the server replays exactly
1885
+ // the gap — no duplicated tail, no hole.
1886
+ conn.bindChat(chatId, hydratedSeqRef.current.get(chatId) || 0);
1875
1887
  connsRef.current.set(chatId, conn);
1876
1888
  // Agent-generated title (session_info_update — fires at turn-end, so a
1877
1889
  // fresh chat shows the client's "New chat" heuristic until the first
@@ -1949,7 +1961,12 @@ export default function ChatPanel({
1949
1961
  return;
1950
1962
  }
1951
1963
  fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`)
1952
- .then((r) => r.json())
1964
+ .then(async (r) => {
1965
+ // Capture the seam marker alongside the body — see hydratedSeqRef.
1966
+ const seq = Number(r.headers.get('X-Maude-Chat-Seq') || 0);
1967
+ hydratedSeqRef.current.set(id, Number.isFinite(seq) ? seq : 0);
1968
+ return r.json();
1969
+ })
1953
1970
  .then((msgs) => openChat(id, toThreadMessages(msgs)))
1954
1971
  .catch(() => {});
1955
1972
  },
@@ -1965,6 +1982,7 @@ export default function ChatPanel({
1965
1982
  connsRef.current.delete(id);
1966
1983
  busyRef.current.delete(id);
1967
1984
  hydratedRef.current.delete(id);
1985
+ hydratedSeqRef.current.delete(id);
1968
1986
  setBusyChats((prev) => {
1969
1987
  const next = { ...prev };
1970
1988
  delete next[id];
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { useEffect, useRef, useState } from 'react';
16
16
 
17
+ import { safeName, syncPresentation } from '../../sync/presentation.ts';
17
18
  import { invoke, isNativeApp, listen, openCloudUrl } from '../github.js';
18
19
 
19
20
  const api = async (path, init) => {
@@ -261,24 +262,56 @@ export function localIdentityHint({
261
262
  * Returns `{ text, title }` — `text` is the rail's one line, `title` the full
262
263
  * sentence on hover (the rail truncates).
263
264
  */
264
- export function connectOutcomeNote(project, sync) {
265
- const name = project || 'the workspace';
265
+ export function connectOutcomeNote(project, sync, live) {
266
+ // The project name and `sync.detail` both originate at the hub, and the
267
+ // fallback path below puts them in trusted app chrome uncapped — including a
268
+ // `title=` tooltip, where a newline renders. `safeName` strips control and
269
+ // bidi-override characters, collapses whitespace and bounds the length; the
270
+ // live path already went through it inside `syncPresentation`.
271
+ const name = safeName(project, 'the workspace');
272
+
273
+ // The LIVE payload wins whenever there is one.
274
+ //
275
+ // `sync` is the attach response, and it reports an intention: the supervisor
276
+ // answers `{ syncing: true }` as soon as `runtime.start()` did not throw and
277
+ // one local canvas qualified — before a socket, before a token was accepted,
278
+ // before a byte moved. Rendered once and never updated, it was the whole of
279
+ // the reported complaint ("vyskočí modal, pak vidím jen syncing canvases, ale
280
+ // reálně se nic nestane"). `live` comes off the `sync:status` bus that is
281
+ // already wired to this app, so the same sentence now MOVES: connecting →
282
+ // syncing 40/75 → synced. That motion is the feedback that was missing.
283
+ //
284
+ // Same rule as the status bar (`syncPresentation`), deliberately — the old
285
+ // hover text told the person to go look at that slot for the real answer,
286
+ // which only worked as long as the two agreed, and they did not.
287
+ if (live) {
288
+ const p = syncPresentation(live, { project: name });
289
+ if (p) {
290
+ const detail = p.names.length ? ` (${p.names.join(', ')})` : '';
291
+ return {
292
+ text: p.title,
293
+ title: `${p.title}${detail}${p.next ? ` — ${p.next}` : ''}`,
294
+ };
295
+ }
296
+ }
297
+
266
298
  if (sync?.syncing) {
267
299
  const n = sync.canvases ?? 0;
268
300
  return {
269
- text: `Syncing with ${name} — ${n} canvas${n === 1 ? '' : 'es'}.`,
270
- title: `This project’s canvases now sync with the ${name} workspace. Live status is in the “hub sync” slot in the status bar.`,
301
+ text: `Connecting to ${name} — ${n} canvas${n === 1 ? '' : 'es'}…`,
302
+ title: `Opening ${n} canvas${n === 1 ? '' : 'es'} against the ${name} workspace. This line updates as they settle.`,
271
303
  };
272
304
  }
273
305
  if (sync?.reason === 'nothing-syncable') {
274
306
  return {
275
307
  text: `Connected to ${name} — nothing to sync yet.`,
276
- title: sync.detail || 'No canvases in this project are syncable yet.',
308
+ title: safeName(sync.detail, 'No canvases in this project are syncable yet.'),
277
309
  };
278
310
  }
311
+ const detail = safeName(sync?.detail, 'restart Maude to start syncing.');
279
312
  return {
280
- text: `Connected to ${name} — ${sync?.detail ?? 'restart Maude to start syncing.'}`,
281
- title: sync?.detail || 'Restart Maude to start syncing.',
313
+ text: `Connected to ${name} — ${detail}`,
314
+ title: detail,
282
315
  };
283
316
  }
284
317
 
@@ -336,14 +369,21 @@ function Icon({ name, size = 15 }) {
336
369
  );
337
370
  }
338
371
 
339
- export default function CloudBar() {
372
+ export default function CloudBar({ syncStatus }) {
340
373
  const [state, setState] = useState('loading'); // loading | out | in
341
374
  const [email, setEmail] = useState(null);
342
375
  const [menuOpen, setMenuOpen] = useState(false);
343
376
  const [device, setDevice] = useState(null); // { userCode, verificationUrl, deviceCode }
344
377
  const [projects, setProjects] = useState(null);
345
378
  const [busy, setBusy] = useState('');
346
- const [note, setNote] = useState(null); // { text, title } — connectOutcomeNote
379
+ // What the last Connect attached, NOT the sentence it produced.
380
+ //
381
+ // This used to hold the rendered `{ text, title }`, which is why the note was
382
+ // frozen: a string computed once at the moment the dialog closed could only
383
+ // ever describe that moment. Keeping the INPUTS and deriving the sentence on
384
+ // every render is the whole liveness mechanism — `syncStatus` arrives on the
385
+ // already-wired `sync:status` bus, so no polling and no second fetch.
386
+ const [connected, setConnected] = useState(null); // { project, sync }
347
387
  const [error, setError] = useState('');
348
388
  const [copied, setCopied] = useState(false);
349
389
  const [copiedLink, setCopiedLink] = useState(false);
@@ -356,6 +396,15 @@ export default function CloudBar() {
356
396
  const railRef = useRef(null);
357
397
  const pollRef = useRef(null);
358
398
 
399
+ // Derived, not stored — so the sentence follows the link instead of freezing
400
+ // at the instant the confirm dialog closed. `syncStatus` is the live payload
401
+ // App already receives; when it is absent (older server, first render) the
402
+ // attach response is still the fallback and the note degrades to what it
403
+ // said before, never to nothing.
404
+ const note = connected
405
+ ? connectOutcomeNote(connected.project, connected.sync, syncStatus)
406
+ : null;
407
+
359
408
  useEffect(() => {
360
409
  api('/_api/cloud/status')
361
410
  .then((r) => {
@@ -426,7 +475,7 @@ export default function CloudBar() {
426
475
  setBusy('');
427
476
  setPending(null);
428
477
  if (r.ok && r.json?.ok) {
429
- setNote(connectOutcomeNote(r.json.project ?? pending.project, r.json.sync));
478
+ setConnected({ project: r.json.project ?? pending.project, sync: r.json.sync });
430
479
  } else {
431
480
  setError(r.json?.error || 'The workspace could not be connected.');
432
481
  }
@@ -503,7 +552,7 @@ export default function CloudBar() {
503
552
 
504
553
  async function connect(projectId) {
505
554
  setBusy(projectId);
506
- setNote(null);
555
+ setConnected(null);
507
556
  setError('');
508
557
  const r = await api('/_api/cloud/attach', {
509
558
  method: 'POST',
@@ -512,7 +561,7 @@ export default function CloudBar() {
512
561
  });
513
562
  setBusy('');
514
563
  if (r.ok && r.json?.ok) {
515
- setNote(connectOutcomeNote(r.json.project ?? projectId, r.json.sync));
564
+ setConnected({ project: r.json.project ?? projectId, sync: r.json.sync });
516
565
  } else {
517
566
  setError(r.json?.error || 'The workspace could not be connected.');
518
567
  }
@@ -705,8 +754,18 @@ export default function CloudBar() {
705
754
  <span className="gi-rail-login">{email ?? 'Maude Cloud'}</span>
706
755
  <span className="gi-rail-caret"><Icon name="chevron-up" size={13} /></span>
707
756
  </button>
757
+ {/* A live region: the sentence now CHANGES as the link settles, and a
758
+ screen-reader user has to hear that transition rather than only
759
+ whatever text happened to be there when they last tabbed past.
760
+ `polite` — it is progress, never an interruption. */}
708
761
  {note && (
709
- <span className="gi-rail-hint" title={note.title} data-testid="cloud-connect-note">
762
+ <span
763
+ className="gi-rail-hint"
764
+ title={note.title}
765
+ role="status"
766
+ aria-live="polite"
767
+ data-testid="cloud-connect-note"
768
+ >
710
769
  {note.text}
711
770
  </span>
712
771
  )}
@@ -54,10 +54,102 @@ export function pickDefaultReject(options) {
54
54
  return list.find((o) => o?.kind === 'reject_once' || o?.kind === 'reject_always') ?? null;
55
55
  }
56
56
 
57
+ // feature-acp-write-path-scope Task 4 — the out-of-project write case.
58
+ //
59
+ // `scope` is attached by the SERVER (bridge.ts `classifyWrite` → index.ts's
60
+ // `permission-request` frame) and only ever for a write tool the path gate
61
+ // refused to auto-approve. Everything this renders comes from that payload: the
62
+ // paths are the bridge's REALPATH-RESOLVED absolutes, never `toolCall.rawInput`,
63
+ // because the model's own string is what makes the write look harmless
64
+ // (`docs/../../../.zshenv` reads as a docs edit). Same lesson as the deep-link
65
+ // modal's truncated project name.
66
+ //
67
+ // Exported pure so the copy is unit-testable without rendering (mirrors
68
+ // pickDefaultAllow's rationale — the affordance IS the security control here).
69
+ // SECURITY (security-auditor F3) — the resolved path is REAL filesystem data,
70
+ // but its non-existent tail is whatever the model asked to create, so it is
71
+ // attacker-influenced text rendered in the one card whose entire job is to be
72
+ // read accurately. React escapes markup; it does NOT neutralize bidi overrides
73
+ // or C0 controls, so `~/safe/‮gnp.esriv/…` can be made to READ as a
74
+ // harmless path while resolving somewhere else entirely.
75
+ //
76
+ // Stripped at RENDER time rather than in the frame: the frame should carry the
77
+ // true path (it is also an audit record), and only the display needs to be
78
+ // unspoofable. Replaced with U+FFFD rather than deleted so a tampered path is
79
+ // visibly odd instead of silently shorter.
80
+ // Written as \u escapes, never literal characters (security-auditor F3 nit) —
81
+ // the same reasoning applied to the tests: a literal RLO in this source would
82
+ // reorder THIS FILE in every editor and code-review diff that renders it.
83
+ // \p{Cc} C0/C1 controls (newline, CR, NUL — hide a whole segment)
84
+ // 202A-202E,2066-2069 bidi embeddings/overrides/isolates (visual reversal)
85
+ // 200E,200F,061C LRM/RLM/ALM — directional marks, same class of trick
86
+ // 200B-200D,FEFF zero-width space/non-joiner/joiner/BOM (invisible splits)
87
+ // 00AD soft hyphen (invisible, but may render as a break)
88
+ const UNSAFE_DISPLAY =
89
+ /[\p{Cc}\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C\u200B-\u200D\uFEFF\u00AD]/gu;
90
+ export function sanitizePathForDisplay(p) {
91
+ return String(p).replace(UNSAFE_DISPLAY, '�');
92
+ }
93
+
94
+ export function describeOutOfProject(scope) {
95
+ if (!scope || scope.outOfProjectWrite !== true) return null;
96
+ const paths = Array.isArray(scope.resolvedPaths)
97
+ ? scope.resolvedPaths.filter(Boolean).map(sanitizePathForDisplay)
98
+ : [];
99
+ const lead =
100
+ scope.reason === 'no-target'
101
+ ? 'Claude asked to write a file but did not say which one.'
102
+ : scope.reason === 'disagreement'
103
+ ? 'Claude named two different files for one write.'
104
+ : // `in-project-denied` is INSIDE the project — saying "outside" would be
105
+ // a plain lie, and the reason it's being asked about is different in
106
+ // kind: not "where is this going" but "this file runs".
107
+ scope.reason === 'in-project-denied'
108
+ ? 'This file is part of how your project RUNS, not part of its design.'
109
+ : paths.length > 1
110
+ ? 'Claude wants to write files outside this project.'
111
+ : 'Claude wants to write a file outside this project.';
112
+ return {
113
+ lead,
114
+ paths,
115
+ scopeRoot: typeof scope.scopeRoot === 'string' ? sanitizePathForDisplay(scope.scopeRoot) : '',
116
+ // Decision D — the server already stripped every `allow_always` option, so
117
+ // there is no "always allow" to explain away. Say so, rather than leaving
118
+ // the user wondering where the usual option went.
119
+ note: 'This is allowed once, for this request only.',
120
+ /** Inside the project, but denied for an execution reason — the copy above
121
+ * must not claim "outside". */
122
+ inProjectDenied: scope.reason === 'in-project-denied',
123
+ };
124
+ }
125
+
126
+ // Decision D is enforced SERVER-side (bridge.ts filters `allow_always` out of
127
+ // the offered set, and validates a response against that same filtered set).
128
+ // This is a second, client-side pass purely so a stale/racing frame can never
129
+ // paint a button that the server would reject anyway — belt-and-braces, not the
130
+ // control itself.
131
+ function visibleOptions(options, outOfProject) {
132
+ const list = Array.isArray(options) ? options : [];
133
+ return outOfProject ? list.filter((o) => o?.kind !== 'allow_always') : list;
134
+ }
135
+
57
136
  export default function PermissionPrompt({ request, onRespond, queueLength = 1 }) {
58
137
  const cardRef = useRef(null);
59
- const { toolCall, options } = request;
138
+ const { toolCall } = request;
60
139
  const target = toolTarget(toolCall);
140
+ const outOfProject = describeOutOfProject(request.scope);
141
+ const options = visibleOptions(request.options, !!outOfProject);
142
+ // See the header comment in the JSX below for why an out-of-project write
143
+ // gets a substituted headline + target instead of the adapter's own.
144
+ const headline =
145
+ request.scope?.reason === 'in-project-denied'
146
+ ? 'Write to a file that runs'
147
+ : 'Write outside this project';
148
+ const headerTarget = outOfProject
149
+ ? outOfProject.paths.length === 1
150
+ ? String(outOfProject.paths[0]).split('/').pop()
151
+ : null
152
+ : target;
61
153
 
62
154
  const firstAllow = pickDefaultAllow(options);
63
155
  const firstReject = pickDefaultReject(options);
@@ -81,16 +173,33 @@ export default function PermissionPrompt({ request, onRespond, queueLength = 1 }
81
173
 
82
174
  return (
83
175
  <div
84
- className="chat-perm"
176
+ className={'chat-perm' + (outOfProject ? ' chat-perm--outside' : '')}
85
177
  role="alertdialog"
86
- aria-label="Permission request"
178
+ aria-label={
179
+ outOfProject
180
+ ? outOfProject.inProjectDenied
181
+ ? 'Permission request — file that runs code'
182
+ : 'Permission request — file outside this project'
183
+ : 'Permission request'
184
+ }
87
185
  tabIndex={-1}
88
186
  ref={cardRef}
89
187
  data-testid="chat-permission-prompt"
90
188
  >
91
189
  <div className="chat-perm-hd">
92
- <b title={toolCall?.title || 'Tool call'}>{toolCall?.title || 'Tool call'}</b>
93
- {target ? <span className="chat-tool-path">{target}</span> : null}
190
+ {/* SECURITY (feature-acp-write-path-scope Task 4) — for an out-of-project
191
+ write the header must NOT echo `toolCall.title`/`rawInput`. Both are
192
+ built from the model's own path string, and the adapter passes it
193
+ through verbatim (see acp/write-scope.ts's measured contract), so the
194
+ title of a write to `~/.zshenv` reads literally "Write
195
+ docs/../../../.zshenv" — a docs edit, to any human skimming the
196
+ header. The card's whole job is to be read; a truthful body under a
197
+ misleading headline is worse than no headline. Substitute a fixed
198
+ label + the RESOLVED basename, and let the body carry the full path. */}
199
+ <b title={outOfProject ? headline : toolCall?.title || 'Tool call'}>
200
+ {outOfProject ? headline : toolCall?.title || 'Tool call'}
201
+ </b>
202
+ {headerTarget ? <span className="chat-tool-path">{headerTarget}</span> : null}
94
203
  {/* SECURITY (ethical-hacker finding) — a growing backlog was
95
204
  invisible (always "1 of 1"), which is what manufactures the
96
205
  reflexive-Enter-mashing precondition the wrong-default bug
@@ -101,7 +210,38 @@ export default function PermissionPrompt({ request, onRespond, queueLength = 1 }
101
210
  </span>
102
211
  ) : null}
103
212
  </div>
104
- <p className="chat-perm-body">Claude wants permission to continue. Choose an option:</p>
213
+ {outOfProject ? (
214
+ <div className="chat-perm-outside" data-testid="chat-perm-outside">
215
+ <p className="chat-perm-body">{outOfProject.lead}</p>
216
+ {outOfProject.paths.length > 0 ? (
217
+ <ul className="chat-perm-paths" data-testid="chat-perm-paths">
218
+ {outOfProject.paths.map((p) => (
219
+ <li key={p} className="chat-perm-path">
220
+ {p}
221
+ </li>
222
+ ))}
223
+ </ul>
224
+ ) : null}
225
+ {outOfProject.scopeRoot ? (
226
+ <p className="chat-perm-scope">
227
+ {outOfProject.inProjectDenied ? (
228
+ <>
229
+ Git hooks and Claude settings can execute code later, so Maude asks even
230
+ though they live inside this project.
231
+ </>
232
+ ) : (
233
+ <>
234
+ This project is <span className="chat-perm-root">{outOfProject.scopeRoot}</span>.
235
+ Edits inside it never ask.
236
+ </>
237
+ )}
238
+ </p>
239
+ ) : null}
240
+ <p className="chat-perm-note">{outOfProject.note}</p>
241
+ </div>
242
+ ) : (
243
+ <p className="chat-perm-body">Claude wants permission to continue. Choose an option:</p>
244
+ )}
105
245
  <div className="chat-perm-actions">
106
246
  {options.map((o) => (
107
247
  <button