threadnote 1.6.1 → 1.6.2

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.
@@ -35501,6 +35501,82 @@ function shouldUseColor() {
35501
35501
  return import_node_process2.stdout.isTTY === true && process.env.NO_COLOR === void 0 && process.env.CI === void 0 && process.env.TERM !== "dumb";
35502
35502
  }
35503
35503
 
35504
+ // src/scrubber.ts
35505
+ var SCRUBBER_PATTERNS = [
35506
+ { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
35507
+ { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
35508
+ { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
35509
+ { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
35510
+ { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
35511
+ { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
35512
+ { name: "basic auth header", regex: /\bBasic\s+[A-Za-z0-9+/]{16,}={0,2}\b/i },
35513
+ { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
35514
+ { name: "AWS access key", regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
35515
+ { name: "AWS secret access key", regex: /\baws_secret_access_key\s*[:=]\s*["']?[A-Za-z0-9/+=]{35,}["']?/i },
35516
+ { name: "AWS session token", regex: /\baws_session_token\s*[:=]\s*["']?[A-Za-z0-9/+=]{40,}["']?/i },
35517
+ { name: "Google API key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/ },
35518
+ { name: "Google OAuth token", regex: /\bya29\.[0-9A-Za-z_-]{20,}/ },
35519
+ { name: "Stripe key", regex: /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}\b/ },
35520
+ { name: "Stripe webhook secret", regex: /\bwhsec_[0-9A-Za-z]{16,}\b/ },
35521
+ {
35522
+ name: "Discord token",
35523
+ regex: /\b(?:mfa\.[A-Za-z0-9_-]{20,}|[MN][A-Za-z0-9_-]{23}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,})\b/
35524
+ },
35525
+ {
35526
+ name: "Discord webhook",
35527
+ regex: /https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/i
35528
+ },
35529
+ { name: "Slack token", regex: /\bx(?:app|ox[abcdeprs])(?:-\d-)?[A-Za-z0-9._-]{10,}/i },
35530
+ { name: "Slack webhook", regex: /https:\/\/hooks\.slack\.com\/services\/[A-Z0-9]+\/[A-Z0-9]+\/[A-Za-z0-9]+/i },
35531
+ {
35532
+ name: "database URI",
35533
+ regex: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis):\/\/[^:\s/@]+:[^@\s]+@[^\s)>"'`,]+/i
35534
+ },
35535
+ { name: "URL basic auth", regex: /\bhttps?:\/\/[^:\s/@]+:[^@\s]+@[^\s)>"'`,]+/i },
35536
+ { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
35537
+ { name: "linux home path", placeholder: "<local-path>", regex: /\/home\/[^\s)>"'`,]+/ }
35538
+ ];
35539
+ function applyScrubber(content, { redact }) {
35540
+ let cleaned = content;
35541
+ const redactions = [];
35542
+ for (const pattern of SCRUBBER_PATTERNS) {
35543
+ if (!pattern.regex.test(cleaned)) {
35544
+ continue;
35545
+ }
35546
+ if (!pattern.placeholder || !redact) {
35547
+ return { blocker: pattern.name, cleaned: content, redactions: [] };
35548
+ }
35549
+ const globalRegex = globalize(pattern.regex);
35550
+ const matches = cleaned.match(globalRegex) ?? [];
35551
+ cleaned = cleaned.replace(globalRegex, pattern.placeholder);
35552
+ redactions.push({ count: matches.length, name: pattern.name });
35553
+ }
35554
+ return { cleaned, redactions };
35555
+ }
35556
+ function credentialScrubberBlocker(content) {
35557
+ for (const pattern of SCRUBBER_PATTERNS) {
35558
+ if (pattern.placeholder === void 0 && pattern.regex.test(content)) {
35559
+ return pattern.name;
35560
+ }
35561
+ }
35562
+ return void 0;
35563
+ }
35564
+ function redactSensitiveText(content) {
35565
+ let redacted = content.replace(
35566
+ /([A-Za-z0-9_.-]*(?:token|secret|password|api[_-]?key|authorization|credential|session)[A-Za-z0-9_.-]*\s*[:=]\s*)("[^"]+"|'[^']+'|Bearer\s+[^'"\s]+|\S+)/gi,
35567
+ "$1[REDACTED]"
35568
+ );
35569
+ for (const pattern of SCRUBBER_PATTERNS) {
35570
+ const placeholder = pattern.placeholder ?? "<secret>";
35571
+ redacted = redacted.replace(globalize(pattern.regex), placeholder);
35572
+ }
35573
+ return redacted;
35574
+ }
35575
+ function globalize(regex) {
35576
+ const flags = regex.flags.includes("g") ? regex.flags : `${regex.flags}g`;
35577
+ return new RegExp(regex.source, flags);
35578
+ }
35579
+
35504
35580
  // src/utils.ts
35505
35581
  function isJsonObject(value) {
35506
35582
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -35514,10 +35590,7 @@ function parseJsonConfigObject(content) {
35514
35590
  }
35515
35591
  }
35516
35592
  function redactText(content) {
35517
- return content.replace(
35518
- /([A-Za-z0-9_.-]*(?:token|secret|password|api[_-]?key|authorization)[A-Za-z0-9_.-]*\s*[:=]\s*)("[^"]+"|'[^']+'|Bearer\s+[^'"\s]+|\S+)/gi,
35519
- "$1[REDACTED]"
35520
- ).replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]").replace(/sk-[A-Za-z0-9_-]{16,}/g, "sk-[REDACTED]").replace(/gh[pousr]_[A-Za-z0-9_]{16,}/g, "gh_[REDACTED]");
35593
+ return redactSensitiveText(content);
35521
35594
  }
35522
35595
  function escapeRegExp(value) {
35523
35596
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -36778,53 +36851,6 @@ var PACK_FILES_DIR = "files";
36778
36851
  var PACK_ROOT_TOKEN = "${THREADNOTE_PACK_ROOT}";
36779
36852
  var AUTO_SHARE_FETCH_INTERVAL_MS = 5 * 60 * 1e3;
36780
36853
  var DEFAULT_GIT_REMOTE_NAME = "origin";
36781
- var SCRUBBER_PATTERNS = [
36782
- // Credentials: never redactable. Blocking is the only safe response —
36783
- // automated redaction risks false negatives that leave material in git.
36784
- { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
36785
- { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
36786
- { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
36787
- { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
36788
- { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
36789
- { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
36790
- // Matches bare JWTs (three base64url segments). May surface a JWE token in
36791
- // legitimate docs; if that becomes noisy we can switch to warn-only.
36792
- { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
36793
- { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
36794
- // Slack tokens: xoxa/xoxb/xoxc (configuration)/xoxd (legacy user cookie)/
36795
- // xoxe (refresh)/xoxp/xoxr/xoxs, with optional -N- segment for the workspace tier.
36796
- { name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i },
36797
- // Soft leaks: block by default (so the agent sees them and decides), but
36798
- // allow opt-in redaction so curated memories with incidental matches can
36799
- // ship without a manual rewrite. Local home paths are the recurring
36800
- // real-world leak; the regexes greedily consume the whole path segment
36801
- // (including subdirectories) up to whitespace or common closing punctuation
36802
- // so redaction collapses an entire path to a single placeholder rather than
36803
- // leaving the subpath visible.
36804
- { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
36805
- // No leading \b: it only matches when a word char precedes the slash, which
36806
- // misses the common cases (after =, :, whitespace, or line start). Mirror the
36807
- // macOS pattern so /home/... is caught in every position.
36808
- { name: "linux home path", placeholder: "<local-path>", regex: /\/home\/[^\s)>"'`,]+/ }
36809
- ];
36810
- function applyScrubber(content, { redact }) {
36811
- let cleaned = content;
36812
- const redactions = [];
36813
- for (const pattern of SCRUBBER_PATTERNS) {
36814
- if (!pattern.regex.test(cleaned)) {
36815
- continue;
36816
- }
36817
- if (!pattern.placeholder || !redact) {
36818
- return { blocker: pattern.name, cleaned: content, redactions: [] };
36819
- }
36820
- const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
36821
- const globalRegex = new RegExp(pattern.regex.source, flags);
36822
- const matches = cleaned.match(globalRegex) ?? [];
36823
- cleaned = cleaned.replace(globalRegex, pattern.placeholder);
36824
- redactions.push({ count: matches.length, name: pattern.name });
36825
- }
36826
- return { cleaned, redactions };
36827
- }
36828
36854
  var autoShareStates = /* @__PURE__ */ new Map();
36829
36855
  function startShareBackgroundFetch(config2) {
36830
36856
  const state = autoShareState(config2);
@@ -37053,7 +37079,7 @@ async function publishShareGitChange(worktree, relativePath, commitMessage, opti
37053
37079
  const git = await requiredExecutable("git");
37054
37080
  const messages = [];
37055
37081
  const paths = typeof relativePath === "string" ? [relativePath] : [...relativePath];
37056
- const stageArgs = verb === "rm" ? ["-C", worktree, "rm", ...paths] : ["-C", worktree, "add", "--", ...paths];
37082
+ const stageArgs = verb === "rm" ? ["-C", worktree, "rm", "--", ...paths] : ["-C", worktree, "add", "--", ...paths];
37057
37083
  const stageResult = await runGitCommand(dryRun, git, stageArgs, `git ${verb} failed`);
37058
37084
  if (stageResult) {
37059
37085
  messages.push(`git ${verb}: ${stageResult.stdout.trim() || "ok"}`);
@@ -37396,13 +37422,7 @@ function isProbablyBinary(buffer) {
37396
37422
  }
37397
37423
  }
37398
37424
  function detectBinaryCredential(buffer) {
37399
- const latin1 = buffer.toString("latin1");
37400
- for (const pattern of SCRUBBER_PATTERNS) {
37401
- if (pattern.placeholder === void 0 && pattern.regex.test(latin1)) {
37402
- return pattern.name;
37403
- }
37404
- }
37405
- return void 0;
37425
+ return credentialScrubberBlocker(buffer.toString("latin1"));
37406
37426
  }
37407
37427
  function detectBinaryLocalPath(buffer, rewriteRoots) {
37408
37428
  const latin1 = buffer.toString("latin1");
@@ -38653,6 +38673,18 @@ function stripPersonalProvenance(content) {
38653
38673
  }
38654
38674
  return cleaned.join("\n");
38655
38675
  }
38676
+ async function readMemoryContent(config2, ov, uri, dryRun) {
38677
+ const args = withIdentity(config2, ["read", uri]);
38678
+ if (dryRun) {
38679
+ console.log(`Would run: ${formatShellCommand(ov, args)}`);
38680
+ return "<dry-run memory body>";
38681
+ }
38682
+ const result = await runCommand(ov, args);
38683
+ if (!result.stdout.trim()) {
38684
+ throw new Error(`Refusing to publish empty memory at ${uri}`);
38685
+ }
38686
+ return result.stdout;
38687
+ }
38656
38688
  async function ensureSharedDirectoryChain(config2, ov, memoryUri, dryRun, options = {}) {
38657
38689
  const directoryUri = parentUri(memoryUri);
38658
38690
  for (const uri of sharedDirectoryChain(config2, directoryUri)) {
@@ -38810,9 +38842,19 @@ function isResourceBusyFailure(stderr, stdout2) {
38810
38842
  ${stdout2}`.toLowerCase();
38811
38843
  return output.includes("resource is busy") || output.includes("resource is being processed");
38812
38844
  }
38813
- async function ingestSingleFile(ov, config2, uri, filePath, initialMode, options = {}) {
38814
- const content = await (0, import_promises4.readFile)(filePath, "utf8");
38815
- await writeMemoryFile(config2, ov, uri, content, initialMode, false, options);
38845
+ async function readSharedInboundFileContent(uri, filePath) {
38846
+ if (!await isRegularFileNoSymlink(filePath)) {
38847
+ throw new Error(`Refusing to ingest non-regular shared file: ${filePath}`);
38848
+ }
38849
+ return prepareSharedInboundContent(uri, await (0, import_promises4.readFile)(filePath, "utf8"));
38850
+ }
38851
+ function prepareSharedInboundContent(uri, rawContent) {
38852
+ const stripped = stripPersonalProvenance(rawContent);
38853
+ const scrub = applyScrubber(stripped, { redact: false });
38854
+ if (scrub.blocker) {
38855
+ throw new Error(`Refusing to ingest ${uri}: possible ${scrub.blocker}. Strip the sensitive value upstream first.`);
38856
+ }
38857
+ return scrub.cleaned;
38816
38858
  }
38817
38859
  async function removeMemoryUri(config2, ov, uri, dryRun, options = {}) {
38818
38860
  const args = withIdentity(config2, ["rm", uri]);
@@ -38860,8 +38902,10 @@ async function gitOutput(worktree, args, dryRun) {
38860
38902
  }
38861
38903
  return result.stdout.trim();
38862
38904
  }
38905
+ var GIT_MODE_ABSENT = "000000";
38906
+ var GIT_MODE_SYMLINK = "120000";
38863
38907
  async function listChangedFiles(worktree, beforeRev, afterRev) {
38864
- const result = await runCommand("git", ["-C", worktree, "diff", "--name-status", "-z", `${beforeRev}..${afterRev}`], {
38908
+ const result = await runCommand("git", ["-C", worktree, "diff", "--raw", "-z", `${beforeRev}..${afterRev}`], {
38865
38909
  allowFailure: true
38866
38910
  });
38867
38911
  if (result.exitCode !== 0) {
@@ -38870,27 +38914,65 @@ async function listChangedFiles(worktree, beforeRev, afterRev) {
38870
38914
  const entries = result.stdout.split("\0").filter((part) => part.length > 0);
38871
38915
  const changes = [];
38872
38916
  for (let index = 0; index < entries.length; ) {
38873
- const raw = entries[index];
38874
- const head = raw.slice(0, 1);
38917
+ const raw = entries[index++];
38918
+ const match = raw.match(/^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])\d*/);
38919
+ if (!match) {
38920
+ continue;
38921
+ }
38922
+ const [, oldMode, newMode, head] = match;
38875
38923
  if (head === "R" || head === "C") {
38876
- const oldRel = entries[index + 1];
38877
- const newRel = entries[index + 2];
38878
- if (oldRel && newRel) {
38879
- changes.push({ path: (0, import_node_path3.join)(worktree, oldRel), relativePath: oldRel, status: "removed" });
38924
+ const oldRel = entries[index];
38925
+ const newRel = entries[index + 1];
38926
+ if (oldRel) {
38927
+ changes.push({
38928
+ path: (0, import_node_path3.join)(worktree, oldRel),
38929
+ previousContent: oldMode === GIT_MODE_SYMLINK ? void 0 : await gitFileContent(worktree, beforeRev, oldRel),
38930
+ relativePath: oldRel,
38931
+ status: "removed"
38932
+ });
38933
+ }
38934
+ if (newRel && newMode !== GIT_MODE_SYMLINK) {
38880
38935
  changes.push({ path: (0, import_node_path3.join)(worktree, newRel), relativePath: newRel, status: "added" });
38881
38936
  }
38882
- index += 3;
38937
+ index += 2;
38883
38938
  continue;
38884
38939
  }
38885
- const rel = entries[index + 1];
38940
+ const rel = entries[index];
38886
38941
  if (rel) {
38887
- const status = head === "A" ? "added" : head === "D" ? "removed" : "modified";
38888
- changes.push({ path: (0, import_node_path3.join)(worktree, rel), relativePath: rel, status });
38942
+ if (head === "D") {
38943
+ changes.push({
38944
+ path: (0, import_node_path3.join)(worktree, rel),
38945
+ previousContent: oldMode === GIT_MODE_SYMLINK ? void 0 : await gitFileContent(worktree, beforeRev, rel),
38946
+ relativePath: rel,
38947
+ status: "removed"
38948
+ });
38949
+ } else if (newMode === GIT_MODE_SYMLINK) {
38950
+ if (oldMode !== GIT_MODE_ABSENT) {
38951
+ changes.push({
38952
+ path: (0, import_node_path3.join)(worktree, rel),
38953
+ previousContent: oldMode === GIT_MODE_SYMLINK ? void 0 : await gitFileContent(worktree, beforeRev, rel),
38954
+ relativePath: rel,
38955
+ status: "removed"
38956
+ });
38957
+ }
38958
+ } else {
38959
+ const status = head === "A" ? "added" : "modified";
38960
+ changes.push({
38961
+ path: (0, import_node_path3.join)(worktree, rel),
38962
+ previousContent: oldMode === GIT_MODE_ABSENT || oldMode === GIT_MODE_SYMLINK ? void 0 : await gitFileContent(worktree, beforeRev, rel),
38963
+ relativePath: rel,
38964
+ status
38965
+ });
38966
+ }
38889
38967
  }
38890
- index += 2;
38968
+ index += 1;
38891
38969
  }
38892
38970
  return changes;
38893
38971
  }
38972
+ async function gitFileContent(worktree, rev, relativePath) {
38973
+ const result = await runCommand("git", ["-C", worktree, "show", `${rev}:${relativePath}`], { allowFailure: true });
38974
+ return result.exitCode === 0 ? result.stdout : void 0;
38975
+ }
38894
38976
  async function applyChangesToOpenViking(config2, team, changes, options = {}) {
38895
38977
  const ov = await openVikingCliForMode(false);
38896
38978
  const failed = [];
@@ -38905,22 +38987,37 @@ async function applyChangesToOpenViking(config2, team, changes, options = {}) {
38905
38987
  const uri = workfileToVikingUri(config2, team, change.path);
38906
38988
  try {
38907
38989
  if (change.status === "removed") {
38990
+ const currentContent2 = await readExistingMemoryContent(config2, ov, uri);
38991
+ if (currentContent2 === void 0) {
38992
+ continue;
38993
+ }
38994
+ assertInboundPreviousContentMatches(change, uri, currentContent2);
38908
38995
  await removeMemoryUri(config2, ov, uri, false, options);
38909
38996
  continue;
38910
38997
  }
38911
- if (!await isFile(change.path)) {
38998
+ if (!await isRegularFileNoSymlink(change.path)) {
38912
38999
  continue;
38913
39000
  }
38914
- const ovHasResource = await vikingResourceExists(ov, config2, uri);
38915
- if (ovHasResource) {
38916
- const reason = change.status === "modified" ? "overwriting local with upstream (local edits to the shared subtree are not preserved across sync)" : "aligning OV to upstream (resource pre-existed in OV, likely from an earlier local publish or sync)";
39001
+ const content = await readSharedInboundFileContent(uri, change.path);
39002
+ const currentContent = await readExistingMemoryContent(config2, ov, uri);
39003
+ if (currentContent !== void 0) {
39004
+ if (change.status === "added") {
39005
+ if (currentContent === content) {
39006
+ continue;
39007
+ }
39008
+ throw new Error(
39009
+ `Refusing to ingest newly added shared file over existing local OpenViking resource ${uri}; inspect and resolve the local edit first.`
39010
+ );
39011
+ }
39012
+ assertInboundPreviousContentMatches(change, uri, currentContent);
39013
+ const reason = change.status === "modified" ? "updating from upstream after verifying local content matches the previous shared version" : "aligning OV to upstream (resource pre-existed in OV, likely from an earlier local publish or sync)";
38917
39014
  if (options.quiet !== true) {
38918
39015
  console.warn(`share sync: ${uri}: ${reason}.`);
38919
39016
  }
38920
39017
  }
38921
39018
  await ensureSharedDirectoryChain(config2, ov, uri, false, options);
38922
- const writeMode = ovHasResource ? "replace" : "create";
38923
- await ingestSingleFile(ov, config2, uri, change.path, writeMode, options);
39019
+ const writeMode = currentContent !== void 0 ? "replace" : "create";
39020
+ await writeMemoryFile(config2, ov, uri, content, writeMode, false, options);
38924
39021
  } catch (err) {
38925
39022
  const message = err instanceof Error ? err.message : String(err);
38926
39023
  if (options.quiet !== true) {
@@ -38931,6 +39028,25 @@ async function applyChangesToOpenViking(config2, team, changes, options = {}) {
38931
39028
  }
38932
39029
  return { failed };
38933
39030
  }
39031
+ async function readExistingMemoryContent(config2, ov, uri) {
39032
+ if (!await vikingResourceExists(ov, config2, uri)) {
39033
+ return void 0;
39034
+ }
39035
+ return readMemoryContent(config2, ov, uri, false);
39036
+ }
39037
+ function assertInboundPreviousContentMatches(change, uri, currentContent) {
39038
+ if (change.previousContent === void 0) {
39039
+ throw new Error(
39040
+ `Refusing to apply inbound shared change for ${uri}: previous shared content is unavailable, so local edits cannot be distinguished from upstream edits.`
39041
+ );
39042
+ }
39043
+ const expectedContent = prepareSharedInboundContent(uri, change.previousContent);
39044
+ if (currentContent !== expectedContent) {
39045
+ throw new Error(
39046
+ `Refusing to apply inbound shared change for ${uri}: local OpenViking content differs from the previous shared version. Inspect and resolve the local edit first.`
39047
+ );
39048
+ }
39049
+ }
38934
39050
  function mergeChanges(...lists) {
38935
39051
  const map2 = /* @__PURE__ */ new Map();
38936
39052
  for (const list of lists) {
@@ -39659,6 +39775,10 @@ function registerTools(server, config2) {
39659
39775
  if (!checkedPattern.ok) {
39660
39776
  return checkedPattern.error;
39661
39777
  }
39778
+ const checkedLiteralPattern = rejectLeadingDash(checkedPattern.value, "grep", "pattern");
39779
+ if (!checkedLiteralPattern.ok) {
39780
+ return checkedLiteralPattern.error;
39781
+ }
39662
39782
  const checkedUri = optionalVikingUri(uri, "grep");
39663
39783
  if (!checkedUri.ok) {
39664
39784
  return checkedUri.error;
@@ -39666,7 +39786,7 @@ function registerTools(server, config2) {
39666
39786
  return runOpenVikingMcpTool(config2, "grep", {
39667
39787
  case_insensitive: caseInsensitive ?? case_insensitive,
39668
39788
  node_limit: nodeLimit ?? node_limit,
39669
- pattern: checkedPattern.value,
39789
+ pattern: checkedLiteralPattern.value,
39670
39790
  uri: checkedUri.value ?? `viking://user/${uriSegment2(config2.user)}/memories`
39671
39791
  });
39672
39792
  }
@@ -39688,13 +39808,17 @@ function registerTools(server, config2) {
39688
39808
  if (!checkedPattern.ok) {
39689
39809
  return checkedPattern.error;
39690
39810
  }
39811
+ const checkedLiteralPattern = rejectLeadingDash(checkedPattern.value, "glob", "pattern");
39812
+ if (!checkedLiteralPattern.ok) {
39813
+ return checkedLiteralPattern.error;
39814
+ }
39691
39815
  const checkedUri = optionalVikingUri(uri, "glob");
39692
39816
  if (!checkedUri.ok) {
39693
39817
  return checkedUri.error;
39694
39818
  }
39695
39819
  return runOpenVikingMcpTool(config2, "glob", {
39696
39820
  node_limit: nodeLimit ?? node_limit,
39697
- pattern: checkedPattern.value,
39821
+ pattern: checkedLiteralPattern.value,
39698
39822
  uri: checkedUri.value
39699
39823
  });
39700
39824
  }
@@ -40007,6 +40131,10 @@ function registerOpenVikingParityTools(server, config2) {
40007
40131
  if (!checkedPattern.ok) {
40008
40132
  return checkedPattern.error;
40009
40133
  }
40134
+ const checkedLiteralPattern = rejectLeadingDash(checkedPattern.value, "ov_grep", "pattern");
40135
+ if (!checkedLiteralPattern.ok) {
40136
+ return checkedLiteralPattern.error;
40137
+ }
40010
40138
  const checkedUri = optionalVikingUri(uri, "ov_grep");
40011
40139
  if (!checkedUri.ok) {
40012
40140
  return checkedUri.error;
@@ -40014,7 +40142,7 @@ function registerOpenVikingParityTools(server, config2) {
40014
40142
  return runOpenVikingMcpTool(config2, "grep", {
40015
40143
  case_insensitive: caseInsensitive ?? case_insensitive,
40016
40144
  node_limit: nodeLimit ?? node_limit,
40017
- pattern: checkedPattern.value,
40145
+ pattern: checkedLiteralPattern.value,
40018
40146
  uri: checkedUri.value
40019
40147
  });
40020
40148
  }
@@ -40036,13 +40164,17 @@ function registerOpenVikingParityTools(server, config2) {
40036
40164
  if (!checkedPattern.ok) {
40037
40165
  return checkedPattern.error;
40038
40166
  }
40167
+ const checkedLiteralPattern = rejectLeadingDash(checkedPattern.value, "ov_glob", "pattern");
40168
+ if (!checkedLiteralPattern.ok) {
40169
+ return checkedLiteralPattern.error;
40170
+ }
40039
40171
  const checkedUri = optionalVikingUri(uri, "ov_glob");
40040
40172
  if (!checkedUri.ok) {
40041
40173
  return checkedUri.error;
40042
40174
  }
40043
40175
  return runOpenVikingMcpTool(config2, "glob", {
40044
40176
  node_limit: nodeLimit ?? node_limit,
40045
- pattern: checkedPattern.value,
40177
+ pattern: checkedLiteralPattern.value,
40046
40178
  uri: checkedUri.value
40047
40179
  });
40048
40180
  }
@@ -40994,6 +41126,17 @@ function requiredText(value, toolName, fieldName, example) {
40994
41126
  ok: false
40995
41127
  };
40996
41128
  }
41129
+ function rejectLeadingDash(value, toolName, fieldName) {
41130
+ if (!value.startsWith("-")) {
41131
+ return { ok: true, value };
41132
+ }
41133
+ return {
41134
+ error: argumentError(
41135
+ `Threadnote MCP tool "${toolName}" rejects "${fieldName}" values that start with "-". Prefix relative file paths with "./" or use an absolute path.`
41136
+ ),
41137
+ ok: false
41138
+ };
41139
+ }
40997
41140
  function requiredVikingUri(value, toolName, exampleUri) {
40998
41141
  const checked = requiredText(value, toolName, "uri", { uri: exampleUri });
40999
41142
  if (!checked.ok) {
@@ -41078,6 +41221,12 @@ async function runOpenVikingAddResourceTool(config2, toolName, params) {
41078
41221
  ].join("\n")
41079
41222
  );
41080
41223
  }
41224
+ if (source) {
41225
+ const checkedSource = rejectLeadingDash(source, toolName, "path");
41226
+ if (!checkedSource.ok) {
41227
+ return checkedSource.error;
41228
+ }
41229
+ }
41081
41230
  if (tempFileId) {
41082
41231
  const checkedTo2 = optionalVikingUri(params.to, toolName);
41083
41232
  if (!checkedTo2.ok) {
@@ -3576,6 +3576,91 @@ function startProgress(message) {
3576
3576
  };
3577
3577
  }
3578
3578
 
3579
+ // src/scrubber.ts
3580
+ var SCRUBBER_PATTERNS = [
3581
+ { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
3582
+ { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
3583
+ { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
3584
+ { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
3585
+ { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
3586
+ { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
3587
+ { name: "basic auth header", regex: /\bBasic\s+[A-Za-z0-9+/]{16,}={0,2}\b/i },
3588
+ { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
3589
+ { name: "AWS access key", regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
3590
+ { name: "AWS secret access key", regex: /\baws_secret_access_key\s*[:=]\s*["']?[A-Za-z0-9/+=]{35,}["']?/i },
3591
+ { name: "AWS session token", regex: /\baws_session_token\s*[:=]\s*["']?[A-Za-z0-9/+=]{40,}["']?/i },
3592
+ { name: "Google API key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/ },
3593
+ { name: "Google OAuth token", regex: /\bya29\.[0-9A-Za-z_-]{20,}/ },
3594
+ { name: "Stripe key", regex: /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}\b/ },
3595
+ { name: "Stripe webhook secret", regex: /\bwhsec_[0-9A-Za-z]{16,}\b/ },
3596
+ {
3597
+ name: "Discord token",
3598
+ regex: /\b(?:mfa\.[A-Za-z0-9_-]{20,}|[MN][A-Za-z0-9_-]{23}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,})\b/
3599
+ },
3600
+ {
3601
+ name: "Discord webhook",
3602
+ regex: /https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/i
3603
+ },
3604
+ { name: "Slack token", regex: /\bx(?:app|ox[abcdeprs])(?:-\d-)?[A-Za-z0-9._-]{10,}/i },
3605
+ { name: "Slack webhook", regex: /https:\/\/hooks\.slack\.com\/services\/[A-Z0-9]+\/[A-Z0-9]+\/[A-Za-z0-9]+/i },
3606
+ {
3607
+ name: "database URI",
3608
+ regex: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis):\/\/[^:\s/@]+:[^@\s]+@[^\s)>"'`,]+/i
3609
+ },
3610
+ { name: "URL basic auth", regex: /\bhttps?:\/\/[^:\s/@]+:[^@\s]+@[^\s)>"'`,]+/i },
3611
+ { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
3612
+ { name: "linux home path", placeholder: "<local-path>", regex: /\/home\/[^\s)>"'`,]+/ }
3613
+ ];
3614
+ function applyScrubber(content, { redact }) {
3615
+ let cleaned = content;
3616
+ const redactions = [];
3617
+ for (const pattern of SCRUBBER_PATTERNS) {
3618
+ if (!pattern.regex.test(cleaned)) {
3619
+ continue;
3620
+ }
3621
+ if (!pattern.placeholder || !redact) {
3622
+ return { blocker: pattern.name, cleaned: content, redactions: [] };
3623
+ }
3624
+ const globalRegex = globalize(pattern.regex);
3625
+ const matches = cleaned.match(globalRegex) ?? [];
3626
+ cleaned = cleaned.replace(globalRegex, pattern.placeholder);
3627
+ redactions.push({ count: matches.length, name: pattern.name });
3628
+ }
3629
+ return { cleaned, redactions };
3630
+ }
3631
+ function detectSecretMatches(content) {
3632
+ const matches = [];
3633
+ for (const pattern of SCRUBBER_PATTERNS) {
3634
+ if (pattern.regex.test(content)) {
3635
+ matches.push(pattern.name);
3636
+ }
3637
+ }
3638
+ return matches;
3639
+ }
3640
+ function credentialScrubberBlocker(content) {
3641
+ for (const pattern of SCRUBBER_PATTERNS) {
3642
+ if (pattern.placeholder === void 0 && pattern.regex.test(content)) {
3643
+ return pattern.name;
3644
+ }
3645
+ }
3646
+ return void 0;
3647
+ }
3648
+ function redactSensitiveText(content) {
3649
+ let redacted = content.replace(
3650
+ /([A-Za-z0-9_.-]*(?:token|secret|password|api[_-]?key|authorization|credential|session)[A-Za-z0-9_.-]*\s*[:=]\s*)("[^"]+"|'[^']+'|Bearer\s+[^'"\s]+|\S+)/gi,
3651
+ "$1[REDACTED]"
3652
+ );
3653
+ for (const pattern of SCRUBBER_PATTERNS) {
3654
+ const placeholder = pattern.placeholder ?? "<secret>";
3655
+ redacted = redacted.replace(globalize(pattern.regex), placeholder);
3656
+ }
3657
+ return redacted;
3658
+ }
3659
+ function globalize(regex) {
3660
+ const flags = regex.flags.includes("g") ? regex.flags : `${regex.flags}g`;
3661
+ return new RegExp(regex.source, flags);
3662
+ }
3663
+
3579
3664
  // src/utils.ts
3580
3665
  function isJsonObject(value) {
3581
3666
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -3589,10 +3674,7 @@ function parseJsonConfigObject(content) {
3589
3674
  }
3590
3675
  }
3591
3676
  function redactText(content) {
3592
- return content.replace(
3593
- /([A-Za-z0-9_.-]*(?:token|secret|password|api[_-]?key|authorization)[A-Za-z0-9_.-]*\s*[:=]\s*)("[^"]+"|'[^']+'|Bearer\s+[^'"\s]+|\S+)/gi,
3594
- "$1[REDACTED]"
3595
- ).replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]").replace(/sk-[A-Za-z0-9_-]{16,}/g, "sk-[REDACTED]").replace(/gh[pousr]_[A-Za-z0-9_]{16,}/g, "gh_[REDACTED]");
3677
+ return redactSensitiveText(content);
3596
3678
  }
3597
3679
  async function walkFiles(root) {
3598
3680
  const files = [];
@@ -8469,53 +8551,6 @@ var PACK_FILES_DIR = "files";
8469
8551
  var PACK_ROOT_TOKEN = "${THREADNOTE_PACK_ROOT}";
8470
8552
  var AUTO_SHARE_FETCH_INTERVAL_MS = 5 * 60 * 1e3;
8471
8553
  var DEFAULT_GIT_REMOTE_NAME = "origin";
8472
- var SCRUBBER_PATTERNS = [
8473
- // Credentials: never redactable. Blocking is the only safe response —
8474
- // automated redaction risks false negatives that leave material in git.
8475
- { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
8476
- { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
8477
- { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
8478
- { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
8479
- { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
8480
- { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
8481
- // Matches bare JWTs (three base64url segments). May surface a JWE token in
8482
- // legitimate docs; if that becomes noisy we can switch to warn-only.
8483
- { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
8484
- { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
8485
- // Slack tokens: xoxa/xoxb/xoxc (configuration)/xoxd (legacy user cookie)/
8486
- // xoxe (refresh)/xoxp/xoxr/xoxs, with optional -N- segment for the workspace tier.
8487
- { name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i },
8488
- // Soft leaks: block by default (so the agent sees them and decides), but
8489
- // allow opt-in redaction so curated memories with incidental matches can
8490
- // ship without a manual rewrite. Local home paths are the recurring
8491
- // real-world leak; the regexes greedily consume the whole path segment
8492
- // (including subdirectories) up to whitespace or common closing punctuation
8493
- // so redaction collapses an entire path to a single placeholder rather than
8494
- // leaving the subpath visible.
8495
- { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
8496
- // No leading \b: it only matches when a word char precedes the slash, which
8497
- // misses the common cases (after =, :, whitespace, or line start). Mirror the
8498
- // macOS pattern so /home/... is caught in every position.
8499
- { name: "linux home path", placeholder: "<local-path>", regex: /\/home\/[^\s)>"'`,]+/ }
8500
- ];
8501
- function applyScrubber(content, { redact }) {
8502
- let cleaned = content;
8503
- const redactions = [];
8504
- for (const pattern of SCRUBBER_PATTERNS) {
8505
- if (!pattern.regex.test(cleaned)) {
8506
- continue;
8507
- }
8508
- if (!pattern.placeholder || !redact) {
8509
- return { blocker: pattern.name, cleaned: content, redactions: [] };
8510
- }
8511
- const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
8512
- const globalRegex = new RegExp(pattern.regex.source, flags);
8513
- const matches = cleaned.match(globalRegex) ?? [];
8514
- cleaned = cleaned.replace(globalRegex, pattern.placeholder);
8515
- redactions.push({ count: matches.length, name: pattern.name });
8516
- }
8517
- return { cleaned, redactions };
8518
- }
8519
8554
  var autoShareStates = /* @__PURE__ */ new Map();
8520
8555
  async function runShareInit(config, remoteUrl, options) {
8521
8556
  if (!remoteUrl.trim()) {
@@ -8538,7 +8573,15 @@ async function runShareInit(config, remoteUrl, options) {
8538
8573
  await ensureDirectory((0, import_node_path7.dirname)(worktree), dryRun);
8539
8574
  await ensureDirectory((0, import_node_path7.dirname)(gitdir), dryRun);
8540
8575
  const git = await requiredExecutable("git");
8541
- await maybeRun(dryRun, git, ["clone", `--separate-git-dir=${gitdir}`, "--", remoteUrl, worktree]);
8576
+ await maybeRun(dryRun, git, [
8577
+ "clone",
8578
+ "-c",
8579
+ "core.symlinks=false",
8580
+ `--separate-git-dir=${gitdir}`,
8581
+ "--",
8582
+ remoteUrl,
8583
+ worktree
8584
+ ]);
8542
8585
  const newConfig = {
8543
8586
  addedAt: (/* @__PURE__ */ new Date()).toISOString(),
8544
8587
  gitdir,
@@ -8972,7 +9015,7 @@ async function publishShareGitChange(worktree, relativePath, commitMessage, opti
8972
9015
  const git = await requiredExecutable("git");
8973
9016
  const messages = [];
8974
9017
  const paths = typeof relativePath === "string" ? [relativePath] : [...relativePath];
8975
- const stageArgs = verb === "rm" ? ["-C", worktree, "rm", ...paths] : ["-C", worktree, "add", "--", ...paths];
9018
+ const stageArgs = verb === "rm" ? ["-C", worktree, "rm", "--", ...paths] : ["-C", worktree, "add", "--", ...paths];
8976
9019
  const stageResult = await runGitCommand(dryRun, git, stageArgs, `git ${verb} failed`);
8977
9020
  if (stageResult) {
8978
9021
  messages.push(`git ${verb}: ${stageResult.stdout.trim() || "ok"}`);
@@ -9386,13 +9429,7 @@ function isProbablyBinary(buffer) {
9386
9429
  }
9387
9430
  }
9388
9431
  function detectBinaryCredential(buffer) {
9389
- const latin1 = buffer.toString("latin1");
9390
- for (const pattern of SCRUBBER_PATTERNS) {
9391
- if (pattern.placeholder === void 0 && pattern.regex.test(latin1)) {
9392
- return pattern.name;
9393
- }
9394
- }
9395
- return void 0;
9432
+ return credentialScrubberBlocker(buffer.toString("latin1"));
9396
9433
  }
9397
9434
  function detectBinaryLocalPath(buffer, rewriteRoots) {
9398
9435
  const latin1 = buffer.toString("latin1");
@@ -10067,7 +10104,7 @@ async function runShareSetUrl(config, remoteUrl, options) {
10067
10104
  const git = await requiredExecutable("git");
10068
10105
  if (dryRun) {
10069
10106
  console.log(
10070
- `Would run: ${formatShellCommand(git, ["-C", team.config.worktree, "remote", "set-url", DEFAULT_GIT_REMOTE_NAME, remoteUrl])}`
10107
+ `Would run: ${formatShellCommand(git, ["-C", team.config.worktree, "remote", "set-url", DEFAULT_GIT_REMOTE_NAME, "--", remoteUrl])}`
10071
10108
  );
10072
10109
  console.log(
10073
10110
  `Would run: ${formatShellCommand(git, ["-C", team.config.worktree, "fetch", DEFAULT_GIT_REMOTE_NAME])}`
@@ -10075,7 +10112,7 @@ async function runShareSetUrl(config, remoteUrl, options) {
10075
10112
  console.log(`Would write teams file: ${teamsFilePath(config)}`);
10076
10113
  return;
10077
10114
  }
10078
- await runCommand(git, ["-C", team.config.worktree, "remote", "set-url", DEFAULT_GIT_REMOTE_NAME, remoteUrl]);
10115
+ await runCommand(git, ["-C", team.config.worktree, "remote", "set-url", DEFAULT_GIT_REMOTE_NAME, "--", remoteUrl]);
10079
10116
  await runCommand(git, ["-C", team.config.worktree, "fetch", DEFAULT_GIT_REMOTE_NAME]);
10080
10117
  const teamsFile = await readTeamsFile(config);
10081
10118
  const updatedTeam = { ...team.config, remote: remoteUrl };
@@ -10383,8 +10420,8 @@ function vikingUriToWorktreeRelative(config, uri, team) {
10383
10420
  }
10384
10421
  async function isRegularFileNoSymlink(path2) {
10385
10422
  try {
10386
- const stat6 = await (0, import_promises5.lstat)(path2);
10387
- return stat6.isFile();
10423
+ const stat5 = await (0, import_promises5.lstat)(path2);
10424
+ return stat5.isFile();
10388
10425
  } catch (_err) {
10389
10426
  return false;
10390
10427
  }
@@ -11123,9 +11160,23 @@ ${stdout2}`.toLowerCase();
11123
11160
  return output2.includes("resource is busy") || output2.includes("resource is being processed");
11124
11161
  }
11125
11162
  async function ingestSingleFile(ov, config, uri, filePath, initialMode, options = {}) {
11126
- const content = await (0, import_promises5.readFile)(filePath, "utf8");
11163
+ const content = await readSharedInboundFileContent(uri, filePath);
11127
11164
  await writeMemoryFile(config, ov, uri, content, initialMode, false, options);
11128
11165
  }
11166
+ async function readSharedInboundFileContent(uri, filePath) {
11167
+ if (!await isRegularFileNoSymlink(filePath)) {
11168
+ throw new Error(`Refusing to ingest non-regular shared file: ${filePath}`);
11169
+ }
11170
+ return prepareSharedInboundContent(uri, await (0, import_promises5.readFile)(filePath, "utf8"));
11171
+ }
11172
+ function prepareSharedInboundContent(uri, rawContent) {
11173
+ const stripped = stripPersonalProvenance(rawContent);
11174
+ const scrub = applyScrubber(stripped, { redact: false });
11175
+ if (scrub.blocker) {
11176
+ throw new Error(`Refusing to ingest ${uri}: possible ${scrub.blocker}. Strip the sensitive value upstream first.`);
11177
+ }
11178
+ return scrub.cleaned;
11179
+ }
11129
11180
  async function removeMemoryUri(config, ov, uri, dryRun, options = {}) {
11130
11181
  const args = withIdentity(config, ["rm", uri]);
11131
11182
  if (dryRun) {
@@ -11172,8 +11223,10 @@ async function gitOutput(worktree, args, dryRun) {
11172
11223
  }
11173
11224
  return result.stdout.trim();
11174
11225
  }
11226
+ var GIT_MODE_ABSENT = "000000";
11227
+ var GIT_MODE_SYMLINK = "120000";
11175
11228
  async function listChangedFiles(worktree, beforeRev, afterRev) {
11176
- const result = await runCommand("git", ["-C", worktree, "diff", "--name-status", "-z", `${beforeRev}..${afterRev}`], {
11229
+ const result = await runCommand("git", ["-C", worktree, "diff", "--raw", "-z", `${beforeRev}..${afterRev}`], {
11177
11230
  allowFailure: true
11178
11231
  });
11179
11232
  if (result.exitCode !== 0) {
@@ -11182,27 +11235,65 @@ async function listChangedFiles(worktree, beforeRev, afterRev) {
11182
11235
  const entries = result.stdout.split("\0").filter((part) => part.length > 0);
11183
11236
  const changes = [];
11184
11237
  for (let index = 0; index < entries.length; ) {
11185
- const raw = entries[index];
11186
- const head = raw.slice(0, 1);
11238
+ const raw = entries[index++];
11239
+ const match = raw.match(/^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])\d*/);
11240
+ if (!match) {
11241
+ continue;
11242
+ }
11243
+ const [, oldMode, newMode, head] = match;
11187
11244
  if (head === "R" || head === "C") {
11188
- const oldRel = entries[index + 1];
11189
- const newRel = entries[index + 2];
11190
- if (oldRel && newRel) {
11191
- changes.push({ path: (0, import_node_path7.join)(worktree, oldRel), relativePath: oldRel, status: "removed" });
11245
+ const oldRel = entries[index];
11246
+ const newRel = entries[index + 1];
11247
+ if (oldRel) {
11248
+ changes.push({
11249
+ path: (0, import_node_path7.join)(worktree, oldRel),
11250
+ previousContent: oldMode === GIT_MODE_SYMLINK ? void 0 : await gitFileContent(worktree, beforeRev, oldRel),
11251
+ relativePath: oldRel,
11252
+ status: "removed"
11253
+ });
11254
+ }
11255
+ if (newRel && newMode !== GIT_MODE_SYMLINK) {
11192
11256
  changes.push({ path: (0, import_node_path7.join)(worktree, newRel), relativePath: newRel, status: "added" });
11193
11257
  }
11194
- index += 3;
11258
+ index += 2;
11195
11259
  continue;
11196
11260
  }
11197
- const rel = entries[index + 1];
11261
+ const rel = entries[index];
11198
11262
  if (rel) {
11199
- const status = head === "A" ? "added" : head === "D" ? "removed" : "modified";
11200
- changes.push({ path: (0, import_node_path7.join)(worktree, rel), relativePath: rel, status });
11263
+ if (head === "D") {
11264
+ changes.push({
11265
+ path: (0, import_node_path7.join)(worktree, rel),
11266
+ previousContent: oldMode === GIT_MODE_SYMLINK ? void 0 : await gitFileContent(worktree, beforeRev, rel),
11267
+ relativePath: rel,
11268
+ status: "removed"
11269
+ });
11270
+ } else if (newMode === GIT_MODE_SYMLINK) {
11271
+ if (oldMode !== GIT_MODE_ABSENT) {
11272
+ changes.push({
11273
+ path: (0, import_node_path7.join)(worktree, rel),
11274
+ previousContent: oldMode === GIT_MODE_SYMLINK ? void 0 : await gitFileContent(worktree, beforeRev, rel),
11275
+ relativePath: rel,
11276
+ status: "removed"
11277
+ });
11278
+ }
11279
+ } else {
11280
+ const status = head === "A" ? "added" : "modified";
11281
+ changes.push({
11282
+ path: (0, import_node_path7.join)(worktree, rel),
11283
+ previousContent: oldMode === GIT_MODE_ABSENT || oldMode === GIT_MODE_SYMLINK ? void 0 : await gitFileContent(worktree, beforeRev, rel),
11284
+ relativePath: rel,
11285
+ status
11286
+ });
11287
+ }
11201
11288
  }
11202
- index += 2;
11289
+ index += 1;
11203
11290
  }
11204
11291
  return changes;
11205
11292
  }
11293
+ async function gitFileContent(worktree, rev, relativePath) {
11294
+ const result = await runCommand("git", ["-C", worktree, "show", `${rev}:${relativePath}`], { allowFailure: true });
11295
+ return result.exitCode === 0 ? result.stdout : void 0;
11296
+ }
11206
11297
  async function applyChangesToOpenViking(config, team, changes, options = {}) {
11207
11298
  const ov = await openVikingCliForMode(false);
11208
11299
  const failed = [];
@@ -11217,22 +11308,37 @@ async function applyChangesToOpenViking(config, team, changes, options = {}) {
11217
11308
  const uri = workfileToVikingUri(config, team, change.path);
11218
11309
  try {
11219
11310
  if (change.status === "removed") {
11311
+ const currentContent2 = await readExistingMemoryContent(config, ov, uri);
11312
+ if (currentContent2 === void 0) {
11313
+ continue;
11314
+ }
11315
+ assertInboundPreviousContentMatches(change, uri, currentContent2);
11220
11316
  await removeMemoryUri(config, ov, uri, false, options);
11221
11317
  continue;
11222
11318
  }
11223
- if (!await isFile(change.path)) {
11319
+ if (!await isRegularFileNoSymlink(change.path)) {
11224
11320
  continue;
11225
11321
  }
11226
- const ovHasResource = await vikingResourceExists(ov, config, uri);
11227
- if (ovHasResource) {
11228
- const reason = change.status === "modified" ? "overwriting local with upstream (local edits to the shared subtree are not preserved across sync)" : "aligning OV to upstream (resource pre-existed in OV, likely from an earlier local publish or sync)";
11322
+ const content = await readSharedInboundFileContent(uri, change.path);
11323
+ const currentContent = await readExistingMemoryContent(config, ov, uri);
11324
+ if (currentContent !== void 0) {
11325
+ if (change.status === "added") {
11326
+ if (currentContent === content) {
11327
+ continue;
11328
+ }
11329
+ throw new Error(
11330
+ `Refusing to ingest newly added shared file over existing local OpenViking resource ${uri}; inspect and resolve the local edit first.`
11331
+ );
11332
+ }
11333
+ assertInboundPreviousContentMatches(change, uri, currentContent);
11334
+ const reason = change.status === "modified" ? "updating from upstream after verifying local content matches the previous shared version" : "aligning OV to upstream (resource pre-existed in OV, likely from an earlier local publish or sync)";
11229
11335
  if (options.quiet !== true) {
11230
11336
  console.warn(`share sync: ${uri}: ${reason}.`);
11231
11337
  }
11232
11338
  }
11233
11339
  await ensureSharedDirectoryChain(config, ov, uri, false, options);
11234
- const writeMode = ovHasResource ? "replace" : "create";
11235
- await ingestSingleFile(ov, config, uri, change.path, writeMode, options);
11340
+ const writeMode = currentContent !== void 0 ? "replace" : "create";
11341
+ await writeMemoryFile(config, ov, uri, content, writeMode, false, options);
11236
11342
  } catch (err) {
11237
11343
  const message = err instanceof Error ? err.message : String(err);
11238
11344
  if (options.quiet !== true) {
@@ -11243,6 +11349,25 @@ async function applyChangesToOpenViking(config, team, changes, options = {}) {
11243
11349
  }
11244
11350
  return { failed };
11245
11351
  }
11352
+ async function readExistingMemoryContent(config, ov, uri) {
11353
+ if (!await vikingResourceExists(ov, config, uri)) {
11354
+ return void 0;
11355
+ }
11356
+ return readMemoryContent(config, ov, uri, false);
11357
+ }
11358
+ function assertInboundPreviousContentMatches(change, uri, currentContent) {
11359
+ if (change.previousContent === void 0) {
11360
+ throw new Error(
11361
+ `Refusing to apply inbound shared change for ${uri}: previous shared content is unavailable, so local edits cannot be distinguished from upstream edits.`
11362
+ );
11363
+ }
11364
+ const expectedContent = prepareSharedInboundContent(uri, change.previousContent);
11365
+ if (currentContent !== expectedContent) {
11366
+ throw new Error(
11367
+ `Refusing to apply inbound shared change for ${uri}: local OpenViking content differs from the previous shared version. Inspect and resolve the local edit first.`
11368
+ );
11369
+ }
11370
+ }
11246
11371
  function mergeChanges(...lists) {
11247
11372
  const map = /* @__PURE__ */ new Map();
11248
11373
  for (const list of lists) {
@@ -12059,8 +12184,8 @@ async function removeVikingResourceWithRetry(ov, config, uri) {
12059
12184
  return false;
12060
12185
  }
12061
12186
  async function vikingResourceExists2(ov, config, uri) {
12062
- const stat6 = await runCommand(ov, withIdentity(config, ["stat", uri]), { allowFailure: true });
12063
- return stat6.exitCode === 0;
12187
+ const stat5 = await runCommand(ov, withIdentity(config, ["stat", uri]), { allowFailure: true });
12188
+ return stat5.exitCode === 0;
12064
12189
  }
12065
12190
  async function ensureDurableMemoryDirectory(ov, config) {
12066
12191
  await ensureMemoryDirectory(ov, config, durableMemoryDirectoryUri(config));
@@ -13686,23 +13811,6 @@ function redactJsonValue(value) {
13686
13811
  function isSensitiveKey(key) {
13687
13812
  return /token|secret|password|credential|authorization|api[_-]?key|client[_-]?secret|bearer/i.test(key);
13688
13813
  }
13689
- function detectSecretMatches(content) {
13690
- const detectors = [
13691
- { label: "private-key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
13692
- { label: "openai-key", regex: /sk-[A-Za-z0-9_-]{24,}/ },
13693
- { label: "github-token", regex: /gh[pousr]_[A-Za-z0-9_]{24,}/ },
13694
- { label: "slack-token", regex: /xox[abprs]-[A-Za-z0-9-]{24,}/ },
13695
- { label: "bearer-token", regex: /Bearer\s+[A-Za-z0-9._~+/=-]{24,}/i },
13696
- { label: "aws-access-key", regex: /AKIA[0-9A-Z]{16}/ }
13697
- ];
13698
- const matches = [];
13699
- for (const detector of detectors) {
13700
- if (detector.regex.test(content)) {
13701
- matches.push(detector.label);
13702
- }
13703
- }
13704
- return matches;
13705
- }
13706
13814
 
13707
13815
  // src/lifecycle.ts
13708
13816
  var import_node_child_process4 = require("node:child_process");
@@ -13821,6 +13929,7 @@ function escapeRegExp3(value) {
13821
13929
  // src/update.ts
13822
13930
  var NPM_PACKAGE_NAME = "threadnote";
13823
13931
  var DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/";
13932
+ var ALLOW_UNTRUSTED_REGISTRY_ENV = "THREADNOTE_ALLOW_UNTRUSTED_NPM_REGISTRY";
13824
13933
  var UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
13825
13934
  var POST_UPDATE_MIGRATIONS_FILE = "post-update-migrations.json";
13826
13935
  var POST_UPDATE_STATE_FILE = "post-update-state.json";
@@ -13851,7 +13960,7 @@ async function maybeNotifyUpdate(config, options = {}) {
13851
13960
  }
13852
13961
  }
13853
13962
  async function runUpdate(config, options) {
13854
- const registry = normalizeRegistry(options.registry ?? updateRegistry());
13963
+ const registry = resolveUpdateRegistry(options.registry, options.allowUntrustedRegistry);
13855
13964
  const info2 = await withSpinner(
13856
13965
  "Checking npm for latest threadnote version",
13857
13966
  () => getUpdateInfo(config, {
@@ -14362,10 +14471,31 @@ function updatePackageCommand(runtime, registry) {
14362
14471
  };
14363
14472
  }
14364
14473
  function normalizeRegistry(registry) {
14365
- return registry.endsWith("/") ? registry : `${registry}/`;
14474
+ const normalized = registry.endsWith("/") ? registry : `${registry}/`;
14475
+ const url = new URL(normalized);
14476
+ if (url.protocol !== "https:") {
14477
+ throw new Error(`npm registry must use https: ${normalized}`);
14478
+ }
14479
+ return url.toString();
14366
14480
  }
14367
14481
  function updateRegistry() {
14368
- return normalizeRegistry(process.env.THREADNOTE_NPM_REGISTRY ?? DEFAULT_NPM_REGISTRY);
14482
+ return resolveUpdateRegistry(void 0, false);
14483
+ }
14484
+ function resolveUpdateRegistry(registry, allowUntrustedRegistry) {
14485
+ const normalized = normalizeRegistry(registry ?? process.env.THREADNOTE_NPM_REGISTRY ?? DEFAULT_NPM_REGISTRY);
14486
+ if (normalized !== DEFAULT_NPM_REGISTRY && !allowsUntrustedRegistry(allowUntrustedRegistry)) {
14487
+ throw new Error(
14488
+ `Refusing custom npm registry ${normalized}: threadnote update does not verify package signatures from alternate registries. Use the default registry, pass --allow-untrusted-registry, or set ${ALLOW_UNTRUSTED_REGISTRY_ENV}=1 only for an approved mirror.`
14489
+ );
14490
+ }
14491
+ return normalized;
14492
+ }
14493
+ function allowsUntrustedRegistry(option) {
14494
+ if (option === true) {
14495
+ return true;
14496
+ }
14497
+ const envValue = process.env[ALLOW_UNTRUSTED_REGISTRY_ENV]?.trim().toLowerCase();
14498
+ return envValue === "1" || envValue === "true" || envValue === "yes";
14369
14499
  }
14370
14500
  function isUpdateNotificationDisabled() {
14371
14501
  return process.env.CI !== void 0 || process.env.NO_UPDATE_NOTIFIER !== void 0 || process.env.THREADNOTE_NO_UPDATE_CHECK !== void 0;
@@ -15875,7 +16005,7 @@ function parsePackageManager(value) {
15875
16005
  // src/version_command.ts
15876
16006
  async function runVersion(config, options) {
15877
16007
  const currentVersion = await currentPackageVersion();
15878
- const registry = normalizeRegistry(options.registry ?? updateRegistry());
16008
+ const registry = resolveUpdateRegistry(options.registry, options.allowUntrustedRegistry);
15879
16009
  let latestVersion;
15880
16010
  let latestWarning;
15881
16011
  try {
@@ -15988,7 +16118,11 @@ async function readManagedMemory(config, uri) {
15988
16118
  if (!path2) {
15989
16119
  throw new Error(`Manager can only read current-user memory URIs: ${uri}`);
15990
16120
  }
15991
- const [content, pathStat] = await Promise.all([(0, import_promises16.readFile)(path2, "utf8"), (0, import_promises16.stat)(path2)]);
16121
+ const pathStat = await (0, import_promises16.lstat)(path2);
16122
+ if (!pathStat.isFile()) {
16123
+ throw new Error(`Manager can only read regular memory files: ${uri}`);
16124
+ }
16125
+ const content = await (0, import_promises16.readFile)(path2, "utf8");
15992
16126
  const relativePath = (0, import_node_path16.relative)(localMemoriesRoot(config), path2).split(import_node_path16.sep).join("/");
15993
16127
  const record = parseMemoryDocument(uri, content);
15994
16128
  return {
@@ -16296,10 +16430,13 @@ async function serveStatic(context, url, response) {
16296
16430
  response.end(content);
16297
16431
  }
16298
16432
  async function readTree(config, path2, uri, relativePath, options = {}) {
16299
- const pathStat = await (0, import_promises16.stat)(path2);
16433
+ const pathStat = await (0, import_promises16.lstat)(path2);
16300
16434
  const name = relativePath ? relativePath.split("/").at(-1) ?? relativePath : options.rootName ?? "memories";
16301
16435
  const isDir = pathStat.isDirectory();
16302
16436
  if (!isDir) {
16437
+ if (!pathStat.isFile()) {
16438
+ throw new Error(`Manager can only read regular files or directories: ${uri}`);
16439
+ }
16303
16440
  const record = options.parseMemoryDocuments === false ? void 0 : parseMemoryDocument(uri, await (0, import_promises16.readFile)(path2, "utf8").catch(() => ""));
16304
16441
  return {
16305
16442
  isDir: false,
@@ -16316,7 +16453,7 @@ async function readTree(config, path2, uri, relativePath, options = {}) {
16316
16453
  }
16317
16454
  const entries = await (0, import_promises16.readdir)(path2, { withFileTypes: true });
16318
16455
  const children = await Promise.all(
16319
- entries.sort(
16456
+ entries.filter((entry) => entry.isDirectory() || entry.isFile()).sort(
16320
16457
  (left, right) => Number(right.isDirectory()) - Number(left.isDirectory()) || left.name.localeCompare(right.name)
16321
16458
  ).map((entry) => {
16322
16459
  const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
@@ -16494,7 +16631,7 @@ async function removeManagedFolder(config, uri) {
16494
16631
  if (!path2) {
16495
16632
  throw new Error(`Manager can only remove current-user memory folders: ${uri}`);
16496
16633
  }
16497
- const pathStat = await (0, import_promises16.stat)(path2);
16634
+ const pathStat = await (0, import_promises16.lstat)(path2);
16498
16635
  if (!pathStat.isDirectory()) {
16499
16636
  throw new Error(`Not a folder: ${uri}`);
16500
16637
  }
@@ -16956,10 +17093,10 @@ async function main() {
16956
17093
  }
16957
17094
  await maybeNotifyUpdate(config, { dryRun: options.dryRun === true });
16958
17095
  });
16959
- program2.command("version").description("Print the installed Threadnote version, latest npm version, and release notes").option("--registry <url>", "npm registry URL", process.env.THREADNOTE_NPM_REGISTRY).action(async (options) => {
17096
+ program2.command("version").description("Print the installed Threadnote version, latest npm version, and release notes").option("--registry <url>", "npm registry URL").option("--allow-untrusted-registry", "Allow a non-default npm registry without package signature verification").action(async (options) => {
16960
17097
  await runVersion(getRuntimeConfig(program2), options);
16961
17098
  });
16962
- program2.command("update").description("Update the published Threadnote package, then repair local shims and MCP config").option("--check", "Only check whether a newer version is available").option("--dry-run", "Print update and repair commands without running them").option("--force", "Run package-manager update even if this version is already current").option("--registry <url>", "npm registry URL", process.env.THREADNOTE_NPM_REGISTRY).option("--runtime <runtime>", "auto, npm, bun, or deno", parseUpdateRuntime, "auto").option("--no-repair", "Skip threadnote repair after updating the package").option("--no-post-update", "Skip post-update migration prompts").option("--yes", "Accept applicable post-update migrations without prompting").action(async (options) => {
17099
+ program2.command("update").description("Update the published Threadnote package, then repair local shims and MCP config").option("--check", "Only check whether a newer version is available").option("--dry-run", "Print update and repair commands without running them").option("--force", "Run package-manager update even if this version is already current").option("--registry <url>", "npm registry URL").option("--allow-untrusted-registry", "Allow a non-default npm registry without package signature verification").option("--runtime <runtime>", "auto, npm, bun, or deno", parseUpdateRuntime, "auto").option("--no-repair", "Skip threadnote repair after updating the package").option("--no-post-update", "Skip post-update migration prompts").option("--yes", "Accept applicable post-update migrations without prompting").action(async (options) => {
16963
17100
  await runUpdate(getRuntimeConfig(program2), options);
16964
17101
  });
16965
17102
  program2.command("post-update", { hidden: true }).description("Run packaged post-update migration prompts").requiredOption("--from-version <version>", "Version before update").requiredOption("--to-version <version>", "Version after update").option("--dry-run", "Print post-update actions without running them").option("--yes", "Accept applicable post-update migrations without prompting").action(async (options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadnote",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "type": "commonjs",
5
5
  "main": "dist/threadnote.cjs",
6
6
  "description": "Shared local context and handoffs for development agents",