synabun 2026.4.20-beta.13 → 2026.4.20-beta.15

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
@@ -2,6 +2,23 @@
2
2
 
3
3
  Raw internal changelogs are archived in [changelog/](changelog/).
4
4
 
5
+ ## 2026-04-21
6
+
7
+ ### Added — Workspace Trust Flow For Windows Git "Dubious Ownership" Repos
8
+ - **New `POST /api/terminal/trust-workspace` endpoint** — In `neural-interface/server.js`, added `detectDubiousOwnership(dir)` helper that probes `git rev-parse --is-inside-work-tree` and matches stderr against `/dubious ownership|safe\.directory/i` (CVE-2022-24765 protection; fires on Windows when the repo is owned by a different SID — external drives, cross-user installs, copied repos). New `normalizeGitPath(dir)` converts backslashes to forward slashes and strips trailing slashes to match the format `git config --add safe.directory` expects. The endpoint validates `path` (non-empty string, no control chars, not `*`/`/`/`\\`, `existsSync` + `isDirectory`), runs `spawnGit(['config', '--global', '--add', 'safe.directory', normalizeGitPath(abs)])`, re-probes to confirm, logs `[git] trusted workspace: <path>`, and returns `{ ok, path, stillDubious }`.
9
+ - **`POST /api/claude-code/integrations` flags dubious workspaces on registration** — The `target === 'project'` branch now calls `detectDubiousOwnership(normalized)` after saving the project to the hook-projects list; when the error fires it adds `dubiousOwnership: true, trustPath: normalizeGitPath(normalized)` to the response JSON. Mac/Linux and healthy Windows installs see no change to response shape.
10
+ - **`promptTrustWorkspace(trustPath)` modal in the file-explorer add-project flow** — In `neural-interface/public/shared/ui-file-explorer.js`, `addProjectFlow()` now reads the integrations response and `await`s a new `promptTrustWorkspace()` modal when `dubiousOwnership` is true. The modal reuses the existing `.fe-modal-overlay` system with a `.fe-trust-modal` amber variant, shows the exact path in a `.fe-trust-path` monospace chip, and offers **Skip** (close, no git change) or **Trust workspace** (POSTs to `/api/terminal/trust-workspace`, toasts the result, closes). The promise resolves either way so the add flow never blocks.
11
+ - **Inline trust row in the onboarding wizard** — In `neural-interface/public/onboarding.html`, the project-add step on `$addBtn` now persists `dubiousOwnership` + `trustPath` fields onto each `wizardState.projects[i]` entry, and `renderProjectList()` appends an amber inline `⚠ Git ownership mismatch — [Trust workspace]` row under the card when flagged. Click POSTs to `/api/terminal/trust-workspace`; on success the row swaps to `✓ Workspace trusted`. Separate from the file-explorer modal because onboarding already has its own card-based flow and shouldn't launch overlay dialogs.
12
+ - **Amber modal styling** — In `neural-interface/public/shared/styles.css`, added `.fe-trust-modal` overrides of `.fe-modal-title` (amber `#ffb74d`) and `.fe-modal-confirm` (warm `rgba(255,183,77,…)`) so the trust dialog is visually distinct from the destructive-red delete modal. New `.fe-trust-path` class styles the monospace path chip with dark background and `word-break: break-all`.
13
+
14
+ ### Fixed — Git UI Silently Disabled On Windows Across Shim / Version / PATH Variants
15
+ - **`git` invocation rewritten to resolve absolute path, then fall back to shell only when needed** — In `neural-interface/server.js`, added `resolveGitPath()` that runs `where git` on Windows / `command -v git` on Unix via `execSync` at first use, caches the result, and prefers `.exe` entries over `.cmd`/`.bat` shims (Scoop, winget) so `spawnSync` can execute git directly without cmd.exe involvement. New `spawnGit(args, opts)` uses the resolved absolute path with no shell on all platforms; only falls back to `shell: true` with per-arg metacharacter quoting when path resolution fails OR the resolved target is a `.cmd`/`.bat` shim. Logs `[git] resolved: <path>` at first use so Windows users can confirm which binary Node is running.
16
+ - **Chained branch-name fallback for old git installs** — `getGitInfo()` and `GET /api/terminal/branches` used `git branch --show-current` which requires git 2.22+ — older Git for Windows builds silently exited non-zero and the branch label disappeared. Added chained fallback: `branch --show-current` → `rev-parse --abbrev-ref HEAD` → `rev-parse --short HEAD`. The `--abbrev-ref` form works on every git version and handles both named branches and detached HEAD (returns literal `"HEAD"`, which the parser treats as "no branch" and escalates to the short SHA).
17
+ - **Plain `git branch` parse replacing `--format=%(refname:short)`** — The branch-list endpoint's `--format=%(refname:short)` arg was prone to cmd.exe variable-expansion mangling on Windows even with double-quoting. Replaced with bare `git branch` and a `line.replace(/^[\s*]+/, '').trim()` parser that strips the current-branch marker and filters out `(HEAD detached …)` lines and rename arrows. Removes the cmd.exe `%(…)` hazard entirely.
18
+ - **All git calls now route through `spawnGit` with unified error logging** — The call sites (`getGitInfo`, `/api/terminal/branches`, `/api/terminal/checkout`, the project-add integration probe) had inconsistent handling — some used raw `execSync`, some `spawnSync` directly. Every git call now goes through `spawnGit(['…'])`, and every failure routes through a new `logGitFailure(where, err)` helper that suppresses the expected "not a git repository" case, deduplicates the "git not found on PATH" warning via `_gitMissingLogged`, and logs everything else with one-line stderr.
19
+ - **Startup `git --version` probe** — Added a `setImmediate(() => spawnGit(['--version']))` at module init that always logs `[git] git version X.Y.Z — OK` or `[git] startup probe FAILED: <reason>. File explorer git UI will be hidden.` — the first diagnostic Windows users should check, since every prior git failure was silently swallowed by the catch blocks.
20
+ - **`windowsHide: true` on every git spawn** — Prevents a momentary cmd.exe console window flash on Windows whenever the file explorer refreshes git status.
21
+
5
22
  ## 2026-04-20
6
23
 
7
24
  ### Added — Install Toggles For Claude / Gemini / Codex / OpenCode In Onboarding Step 5
@@ -2154,15 +2154,58 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
2154
2154
  wizardState.projects.forEach((p, i) => {
2155
2155
  const card = document.createElement('div');
2156
2156
  card.className = 'onboard-project-card';
2157
- card.style.cssText = 'display:flex;align-items:center;gap:10px;padding:10px 12px;background:rgba(109,213,140,0.06);border:1px solid rgba(109,213,140,0.2);border-radius:8px;font-size:12px';
2157
+ card.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:rgba(109,213,140,0.06);border:1px solid rgba(109,213,140,0.2);border-radius:8px;font-size:12px';
2158
+ const safePath = p.path.replace(/\\/g, '/');
2158
2159
  card.innerHTML = `
2159
- <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:rgba(109,213,140,0.8);stroke-width:2;flex-shrink:0"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
2160
- <div style="flex:1;min-width:0">
2161
- <div style="color:var(--t-bright);font-weight:500">${p.label}</div>
2162
- <div style="color:var(--t-muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${p.path.replace(/\\/g, '/')}">${p.path.replace(/\\/g, '/')}</div>
2160
+ <div style="display:flex;align-items:center;gap:10px">
2161
+ <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:rgba(109,213,140,0.8);stroke-width:2;flex-shrink:0"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
2162
+ <div style="flex:1;min-width:0">
2163
+ <div style="color:var(--t-bright);font-weight:500">${p.label}</div>
2164
+ <div style="color:var(--t-muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${safePath}">${safePath}</div>
2165
+ </div>
2166
+ <button class="onboard-project-remove" style="background:none;border:none;color:var(--t-muted);cursor:pointer;font-size:16px;padding:2px 6px;line-height:1;opacity:0.5" title="Remove">&times;</button>
2163
2167
  </div>
2164
- <button class="onboard-project-remove" style="background:none;border:none;color:var(--t-muted);cursor:pointer;font-size:16px;padding:2px 6px;line-height:1;opacity:0.5" title="Remove">&times;</button>
2165
2168
  `;
2169
+ if (p.dubiousOwnership && !p.trusted) {
2170
+ const trustRow = document.createElement('div');
2171
+ trustRow.className = 'onboard-trust-row';
2172
+ trustRow.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:rgba(255,183,77,0.08);border:1px solid rgba(255,183,77,0.25);border-radius:6px;font-size:11px;color:rgba(255,204,128,0.9)';
2173
+ trustRow.innerHTML = `
2174
+ <span>&#9888; Git ownership mismatch &mdash; workspace needs to be trusted</span>
2175
+ <button class="onboard-trust-btn" style="margin-left:auto;background:rgba(255,183,77,0.2);border:1px solid rgba(255,183,77,0.35);color:#ffe0b2;padding:4px 10px;border-radius:5px;font-size:11px;cursor:pointer">Trust workspace</button>
2176
+ `;
2177
+ const $btn = trustRow.querySelector('.onboard-trust-btn');
2178
+ $btn.addEventListener('click', async () => {
2179
+ $btn.disabled = true;
2180
+ $btn.textContent = 'Trusting...';
2181
+ try {
2182
+ const r = await fetch('/api/terminal/trust-workspace', {
2183
+ method: 'POST',
2184
+ headers: { 'Content-Type': 'application/json' },
2185
+ body: JSON.stringify({ path: p.trustPath || safePath }),
2186
+ });
2187
+ const d = await r.json();
2188
+ if (d.ok) {
2189
+ p.trusted = true;
2190
+ renderProjectList();
2191
+ } else {
2192
+ $btn.disabled = false;
2193
+ $btn.textContent = 'Trust workspace';
2194
+ $error.textContent = d.error || 'Trust failed';
2195
+ }
2196
+ } catch {
2197
+ $btn.disabled = false;
2198
+ $btn.textContent = 'Trust workspace';
2199
+ $error.textContent = 'Trust failed';
2200
+ }
2201
+ });
2202
+ card.appendChild(trustRow);
2203
+ } else if (p.trusted) {
2204
+ const okRow = document.createElement('div');
2205
+ okRow.style.cssText = 'font-size:11px;color:rgba(109,213,140,0.8)';
2206
+ okRow.textContent = '✓ Workspace trusted';
2207
+ card.appendChild(okRow);
2208
+ }
2166
2209
  card.querySelector('.onboard-project-remove').addEventListener('click', () => {
2167
2210
  wizardState.projects.splice(i, 1);
2168
2211
  renderProjectList();
@@ -2212,15 +2255,16 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
2212
2255
 
2213
2256
  // Register via API
2214
2257
  $addBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px"></div> Registering...';
2258
+ let integData = null;
2215
2259
  try {
2216
2260
  const res = await fetch('/api/claude-code/integrations', {
2217
2261
  method: 'POST',
2218
2262
  headers: { 'Content-Type': 'application/json' },
2219
2263
  body: JSON.stringify({ target: 'project', projectPath: folderPath, label }),
2220
2264
  });
2221
- const data = await res.json();
2222
- if (!data.ok) {
2223
- $error.textContent = data.error || 'Failed to register project.';
2265
+ integData = await res.json();
2266
+ if (!integData.ok) {
2267
+ $error.textContent = integData.error || 'Failed to register project.';
2224
2268
  $addBtn.disabled = false;
2225
2269
  $addBtn.innerHTML = origHTML;
2226
2270
  return;
@@ -2232,7 +2276,12 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
2232
2276
  return;
2233
2277
  }
2234
2278
 
2235
- wizardState.projects.push({ path: folderPath, label });
2279
+ const projectEntry = { path: folderPath, label };
2280
+ if (integData && integData.dubiousOwnership && integData.trustPath) {
2281
+ projectEntry.dubiousOwnership = true;
2282
+ projectEntry.trustPath = integData.trustPath;
2283
+ }
2284
+ wizardState.projects.push(projectEntry);
2236
2285
  $addBtn.disabled = false;
2237
2286
  $addBtn.innerHTML = origHTML;
2238
2287
  renderProjectList();
@@ -13868,6 +13868,23 @@
13868
13868
  }
13869
13869
  .fe-modal-confirm:hover { background: rgba(229,115,115,0.35); color: #ffab91; }
13870
13870
 
13871
+ /* ── Trust-workspace modal (amber accent, different from destructive red) ── */
13872
+ .fe-trust-modal .fe-modal-title { color: #ffb74d; }
13873
+ .fe-trust-modal .fe-modal-confirm { background: rgba(255,183,77,0.18); color: #ffcc80; }
13874
+ .fe-trust-modal .fe-modal-confirm:hover { background: rgba(255,183,77,0.3); color: #ffe0b2; }
13875
+ .fe-trust-modal .fe-modal-confirm:disabled { opacity: 0.5; cursor: default; }
13876
+ .fe-trust-path {
13877
+ margin-top: 10px;
13878
+ padding: 8px 10px;
13879
+ background: rgba(0,0,0,0.3);
13880
+ border: 1px solid rgba(255,255,255,0.08);
13881
+ border-radius: 6px;
13882
+ font-family: var(--font-mono, ui-monospace, monospace);
13883
+ font-size: 11px;
13884
+ color: rgba(255,255,255,0.6);
13885
+ word-break: break-all;
13886
+ }
13887
+
13871
13888
  /* ── Color palette popup ── */
13872
13889
  .fe-color-palette {
13873
13890
  position: absolute;
@@ -648,7 +648,13 @@ async function addProjectFlow() {
648
648
 
649
649
  // Register as a project
650
650
  const label = data.path.split(/[\\/]/).pop() || data.path;
651
- await fetch('/api/claude-code/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'project', projectPath: data.path, label }) });
651
+ const integRes = await fetch('/api/claude-code/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'project', projectPath: data.path, label }) });
652
+ const integData = await integRes.json().catch(() => ({}));
653
+
654
+ // Windows git CVE-2022-24765 safe.directory prompt — only fires when server detected the specific error.
655
+ if (integData && integData.dubiousOwnership && integData.trustPath) {
656
+ await promptTrustWorkspace(integData.trustPath);
657
+ }
652
658
 
653
659
  // Refresh projects list and select the new one
654
660
  await fetchProjects();
@@ -665,6 +671,67 @@ async function addProjectFlow() {
665
671
  }
666
672
  }
667
673
 
674
+ function promptTrustWorkspace(trustPath) {
675
+ return new Promise((resolve) => {
676
+ const existing = document.getElementById('fe-trust-modal');
677
+ if (existing) existing.remove();
678
+
679
+ const overlay = document.createElement('div');
680
+ overlay.id = 'fe-trust-modal';
681
+ overlay.className = 'fe-modal-overlay';
682
+
683
+ const modal = document.createElement('div');
684
+ modal.className = 'fe-modal fe-trust-modal';
685
+ modal.innerHTML = `
686
+ <div class="fe-modal-title">Git ownership mismatch</div>
687
+ <div class="fe-modal-body">
688
+ Git is refusing to read this workspace because it's owned by a different user account. Trusting it adds the directory to your global <code>safe.directory</code> config so Synabun's git UI can show the branch and changes.
689
+ <div class="fe-trust-path" id="fe-trust-path"></div>
690
+ </div>
691
+ <div class="fe-modal-actions">
692
+ <button class="fe-modal-btn fe-modal-cancel" id="fe-trust-skip">Skip</button>
693
+ <button class="fe-modal-btn fe-modal-confirm" id="fe-trust-confirm">Trust workspace</button>
694
+ </div>
695
+ `;
696
+ overlay.appendChild(modal);
697
+ document.body.appendChild(overlay);
698
+ modal.querySelector('#fe-trust-path').textContent = trustPath;
699
+
700
+ requestAnimationFrame(() => overlay.classList.add('fe-modal-overlay--in'));
701
+
702
+ const done = () => {
703
+ overlay.classList.remove('fe-modal-overlay--in');
704
+ setTimeout(() => overlay.remove(), 150);
705
+ document.removeEventListener('keydown', onKey);
706
+ resolve();
707
+ };
708
+ const onKey = (ev) => { if (ev.key === 'Escape') done(); };
709
+ document.addEventListener('keydown', onKey);
710
+
711
+ modal.querySelector('#fe-trust-skip').addEventListener('click', done);
712
+ overlay.addEventListener('click', (ev) => { if (ev.target === overlay) done(); });
713
+
714
+ modal.querySelector('#fe-trust-confirm').addEventListener('click', async () => {
715
+ const btn = modal.querySelector('#fe-trust-confirm');
716
+ btn.disabled = true;
717
+ btn.textContent = 'Trusting...';
718
+ try {
719
+ const res = await fetch('/api/terminal/trust-workspace', {
720
+ method: 'POST',
721
+ headers: { 'Content-Type': 'application/json' },
722
+ body: JSON.stringify({ path: trustPath }),
723
+ });
724
+ const data = await res.json();
725
+ if (data.ok) showToast('Workspace trusted');
726
+ else showToast(data.error || 'Trust failed');
727
+ } catch {
728
+ showToast('Trust failed');
729
+ }
730
+ done();
731
+ });
732
+ });
733
+ }
734
+
668
735
  function initProjectSelector() {
669
736
  const btn = $('fe-project-btn');
670
737
  const wrap = $('fe-project-selector');
@@ -1704,21 +1771,21 @@ function focusEditorLocation(lineNum, columnNum = 1) {
1704
1771
  syncGutterScroll();
1705
1772
  }
1706
1773
 
1707
- async function openFileEditor(filePath, opts = {}) {
1708
- try {
1709
- const normalizedPath = String(filePath || '').replace(/\\/g, '/');
1710
- const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
1711
- const data = await res.json();
1712
- if (!res.ok) {
1713
- const isOpencodeConfig = normalizedPath.endsWith('/.config/opencode/config.json');
1714
- const isAllowlistMiss = res.status === 403 && data?.error === 'Path outside registered project roots';
1715
- if (isOpencodeConfig && isAllowlistMiss) {
1716
- showToast('Restart Synabun to apply OpenCode config editor support');
1717
- return;
1718
- }
1719
- showToast(data.error || 'Cannot open file');
1720
- return;
1721
- }
1774
+ async function openFileEditor(filePath, opts = {}) {
1775
+ try {
1776
+ const normalizedPath = String(filePath || '').replace(/\\/g, '/');
1777
+ const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
1778
+ const data = await res.json();
1779
+ if (!res.ok) {
1780
+ const isOpencodeConfig = normalizedPath.endsWith('/.config/opencode/config.json');
1781
+ const isAllowlistMiss = res.status === 403 && data?.error === 'Path outside registered project roots';
1782
+ if (isOpencodeConfig && isAllowlistMiss) {
1783
+ showToast('Restart Synabun to apply OpenCode config editor support');
1784
+ return;
1785
+ }
1786
+ showToast(data.error || 'Cannot open file');
1787
+ return;
1788
+ }
1722
1789
 
1723
1790
  _editorOpen = true;
1724
1791
  _editorFilePath = data.path;
@@ -2269,6 +2269,7 @@ function buildProjectsTab(ccIntegrations) {
2269
2269
  </div>
2270
2270
  <div class="cc-panel-actions">
2271
2271
  <button class="cc-explore-btn" data-cc-explore="${i}" style="background:var(--accent-blue-bg);border:1px solid var(--accent-blue-border);color:var(--accent-blue);padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">Learn it</button>
2272
+ <button class="cc-trust-btn" data-cc-trust="${i}" title="Add this workspace to git safe.directory (fixes 'dubious ownership' errors)" style="background:rgba(255,183,77,0.14);border:1px solid rgba(255,183,77,0.3);color:#ffcc80;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">Trust workspace</button>
2272
2273
  <button class="cc-enable-btn${p.installed ? ' on' : ''}" data-cc-project-toggle="${i}">${p.installed ? 'Enabled' : 'Enable'}</button>
2273
2274
  <button class="cc-remove-panel-btn" data-cc-remove="${i}">Remove</button>
2274
2275
  </div>
@@ -6147,6 +6148,43 @@ export async function openSettingsModal() {
6147
6148
  });
6148
6149
  });
6149
6150
 
6151
+ // Trust workspace (adds to git safe.directory global config)
6152
+ overlay.querySelectorAll('.cc-trust-btn[data-cc-trust]').forEach(btn => {
6153
+ btn.addEventListener('click', async (e) => {
6154
+ e.stopPropagation();
6155
+ const idx = btn.dataset.ccTrust;
6156
+ const panel = overlay.querySelector(`.cc-panel[data-cc-idx="${idx}"]`);
6157
+ const projectPath = panel?.dataset.ccPath;
6158
+ if (!projectPath) return;
6159
+ const origText = btn.textContent;
6160
+ btn.disabled = true;
6161
+ btn.textContent = 'Trusting...';
6162
+ try {
6163
+ const res = await fetch('/api/terminal/trust-workspace', {
6164
+ method: 'POST',
6165
+ headers: { 'Content-Type': 'application/json' },
6166
+ body: JSON.stringify({ path: projectPath }),
6167
+ });
6168
+ const data = await res.json();
6169
+ if (data.ok) {
6170
+ btn.textContent = '✓ Trusted';
6171
+ btn.style.background = 'rgba(109,213,140,0.14)';
6172
+ btn.style.borderColor = 'rgba(109,213,140,0.3)';
6173
+ btn.style.color = '#a5d6a7';
6174
+ setTimeout(() => { btn.disabled = false; btn.textContent = 'Trust workspace'; btn.style.background = 'rgba(255,183,77,0.14)'; btn.style.borderColor = 'rgba(255,183,77,0.3)'; btn.style.color = '#ffcc80'; }, 2500);
6175
+ } else {
6176
+ btn.disabled = false;
6177
+ btn.textContent = origText;
6178
+ alert('Trust failed: ' + (data.error || 'unknown error'));
6179
+ }
6180
+ } catch (err) {
6181
+ btn.disabled = false;
6182
+ btn.textContent = origText;
6183
+ alert('Trust failed: ' + err.message);
6184
+ }
6185
+ });
6186
+ });
6187
+
6150
6188
  // Remove project
6151
6189
  overlay.querySelectorAll('.cc-remove-panel-btn[data-cc-remove]').forEach(btn => {
6152
6190
  btn.addEventListener('click', async (e) => {
@@ -12755,6 +12755,18 @@ app.post('/api/claude-code/integrations', (req, res) => {
12755
12755
  // Create default category tree for the project (parent + children)
12756
12756
  try { ensureProjectCategories(); } catch {}
12757
12757
 
12758
+ // Probe for git "dubious ownership" — signals the UI to show an explicit trust prompt.
12759
+ // Only relevant on Windows when the project dir is owned by a different SID than the
12760
+ // account running Node (external drives, copied repos, etc.). No-op on Mac/Linux.
12761
+ let dubiousOwnership = false;
12762
+ let trustPath = null;
12763
+ try {
12764
+ if (detectDubiousOwnership(normalized)) {
12765
+ dubiousOwnership = true;
12766
+ trustPath = normalizeGitPath(normalized);
12767
+ }
12768
+ } catch { /* non-critical */ }
12769
+
12758
12770
  // Auto-create greeting config entry for the new project
12759
12771
  try {
12760
12772
  const greetingKey = projectLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
@@ -12774,7 +12786,7 @@ app.post('/api/claude-code/integrations', (req, res) => {
12774
12786
  }
12775
12787
  } catch { /* non-critical */ }
12776
12788
 
12777
- return res.json({ ok: true, message: `Hook enabled for ${basename(normalized)}.` });
12789
+ return res.json({ ok: true, message: `Hook enabled for ${basename(normalized)}.`, dubiousOwnership, trustPath });
12778
12790
  }
12779
12791
 
12780
12792
  res.status(400).json({ error: 'Invalid target. Use "global" or "project".' });
@@ -17156,6 +17168,22 @@ setImmediate(() => {
17156
17168
  }
17157
17169
  });
17158
17170
 
17171
+ // Git "dubious ownership" detection — CVE-2022-24765 safety check refuses to operate on
17172
+ // repos owned by a different OS user/SID. Common on Windows external drives or repos
17173
+ // copied from another machine. Returns true only when git emits the specific error.
17174
+ function detectDubiousOwnership(dir) {
17175
+ const r = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
17176
+ if (r.status === 0) return false;
17177
+ const stderr = r.stderr?.toString?.() || '';
17178
+ return /dubious ownership|safe\.directory/i.test(stderr);
17179
+ }
17180
+
17181
+ // Normalize a filesystem path for `git config --add safe.directory` — forward-slashed
17182
+ // absolute path, no trailing slash.
17183
+ function normalizeGitPath(dir) {
17184
+ return String(dir).replace(/\\/g, '/').replace(/\/+$/, '');
17185
+ }
17186
+
17159
17187
  /** Try to get git status for a directory. Returns { branch, statuses } or null. */
17160
17188
  function getGitInfo(dir) {
17161
17189
  // Check if inside a git work tree (spawnGit for consistent path resolution)
@@ -17353,6 +17381,38 @@ app.get('/api/terminal/branches', (req, res) => {
17353
17381
  }
17354
17382
  });
17355
17383
 
17384
+ // Trust a workspace path with git — runs `git config --global --add safe.directory <path>`
17385
+ // so git stops refusing to operate on it (CVE-2022-24765 dubious-ownership protection).
17386
+ // Triggered by the add-project UI flows when the server detects the dubious-ownership error.
17387
+ app.post('/api/terminal/trust-workspace', (req, res) => {
17388
+ const { path: dir } = req.body || {};
17389
+ if (!dir || typeof dir !== 'string') return res.status(400).json({ ok: false, error: 'path required' });
17390
+ if (/[\x00-\x1f]/.test(dir) || dir === '*' || dir === '/' || dir === '\\') {
17391
+ return res.status(400).json({ ok: false, error: 'Invalid path' });
17392
+ }
17393
+ let abs;
17394
+ try {
17395
+ abs = resolve(dir);
17396
+ if (!existsSync(abs)) return res.status(400).json({ ok: false, error: `Directory not found: ${abs}` });
17397
+ if (!statSync(abs).isDirectory()) return res.status(400).json({ ok: false, error: 'Path is not a directory' });
17398
+ } catch (err) {
17399
+ return res.status(400).json({ ok: false, error: err.message });
17400
+ }
17401
+
17402
+ const gitPath = normalizeGitPath(abs);
17403
+ const r = spawnGit(['config', '--global', '--add', 'safe.directory', gitPath]);
17404
+ if (r.status !== 0) {
17405
+ const stderr = r.stderr?.toString?.()?.trim() || r.error?.message || 'git config failed';
17406
+ logGitFailure('config --add safe.directory', r.error || { stderr: r.stderr });
17407
+ return res.status(500).json({ ok: false, error: stderr });
17408
+ }
17409
+
17410
+ // Confirm by re-probing
17411
+ const stillDubious = detectDubiousOwnership(abs);
17412
+ console.log(`[git] trusted workspace: ${gitPath}${stillDubious ? ' (but still flagged — check permissions)' : ''}`);
17413
+ res.json({ ok: true, path: gitPath, stillDubious });
17414
+ });
17415
+
17356
17416
  app.post('/api/terminal/checkout', (req, res) => {
17357
17417
  const { path: dir, branch } = req.body;
17358
17418
  if (!dir || !branch) return res.status(400).json({ error: 'path and branch required' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synabun",
3
- "version": "2026.4.20-beta.13",
3
+ "version": "2026.4.20-beta.15",
4
4
  "description": "Persistent vector memory for AI assistants",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The SynaBun Authors",