{"version":3,"file":"feedback-sanitize.mjs","names":[],"sources":["../src/feedback-sanitize.ts"],"sourcesContent":["/**\n * Deterministic, regex-based redaction for agent-authored feedback text.\n *\n * Both agent surfaces (`@stll/anonymize-cli`, `@stll/anonymize-mcp`) can file a\n * bug or gap via feedback. The free-text title/body can accidentally carry a\n * client email, an id, an auth token, or an internal URL. This module strips the\n * obvious shapes before the text is ever shown to a human or placed into a\n * prefilled GitHub issue URL. It is a coarse safety net, not a guarantee: the\n * real control is human approval (nothing is published until the human opens and\n * submits the prefilled issue) and the fact that this surface never sends over\n * the network. The heavy WASM anonymization pipeline is deliberately not run\n * here: feedback is short free text, and regex plus human approval is the\n * accepted baseline (it also keeps this module runtime-free).\n *\n * Pass order is load-bearing: JWT/secret shapes run before URL so a secret in a\n * query string of a preserved public URL is still redacted while the URL is kept.\n */\n\nconst REDACTED_EMAIL = \"[redacted-email]\";\nconst REDACTED_ID = \"[redacted-id]\";\nconst REDACTED_SECRET = \"[redacted-secret]\";\nconst REDACTED_URL = \"[redacted-url]\";\nconst REDACTED_IP = \"[redacted-ip]\";\n\nconst hasNoPrivateUrlParts = (url: URL): boolean =>\n  url.username === \"\" &&\n  url.password === \"\" &&\n  url.search === \"\" &&\n  url.hash === \"\";\n\n/**\n * The only URL preserved verbatim is the project's own public GitHub repo, so a\n * feedback body can reference an existing issue or file without being redacted.\n * Everything else (including other hosts) is stripped.\n */\nconst isPreservedPublicUrl = (url: URL): boolean => {\n  if (!hasNoPrivateUrlParts(url)) {\n    return false;\n  }\n  if (url.hostname.toLowerCase() !== \"github.com\") {\n    return false;\n  }\n  return (\n    url.pathname === \"/stella/anonymize\" ||\n    url.pathname.startsWith(\"/stella/anonymize/\")\n  );\n};\n\n// Three dot-separated base64url segments, each long enough to be a real token\n// (>= 10 chars), so version strings (\"1.2.3\") and IPv4 literals never match.\nconst JWT_REGEX =\n  /\\b[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b/gu;\n\n// Long hex blob (>= 32 chars): API keys, hashes, un-hyphenated ids.\nconst HEX_SECRET_REGEX = /\\b[0-9a-fA-F]{32,}\\b/gu;\n\n// Long base64url blob (>= 40 chars): opaque access tokens, secrets. The\n// base64url alphabet (no `+` or `/`) is used on purpose: including `/` would let\n// this pass swallow whole URL path segments, and modern tokens (GitHub PATs, JWT\n// parts, most API keys) are base64url anyway. A hex secret is caught by\n// HEX_SECRET_REGEX above.\nconst BASE64_SECRET_REGEX = /\\b[A-Za-z0-9_-]{40,}={0,2}/gu;\n\n// Absolute http(s) URL. Parentheses/brackets are valid path characters and are\n// intentionally included; unmatched closing wrappers and sentence punctuation\n// are trimmed in the replacer.\nconst URL_REGEX = /\\bhttps?:\\/\\/[^\\s<>\"'`]+/giu;\nconst URL_TRAILING_PUNCTUATION_REGEX = /[.,;:!?]+$/u;\n\nconst EMAIL_REGEX = /\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b/gu;\n\nconst UUID_REGEX =\n  /\\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\b/gu;\n\nconst IPV4_REGEX = /\\b\\d{1,3}(?:\\.\\d{1,3}){3}\\b/gu;\n\n// Full-form and mid/tail-compressed IPv6. Fully leading-compressed forms\n// (\"::1\") are intentionally out of scope: requiring at least one leading hex\n// group keeps code tokens like `std::vector` from being misread as an address.\nconst IPV6_REGEX =\n  /\\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\\b|\\b(?:[0-9a-fA-F]{1,4}:){1,6}:(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\\b/gu;\n\nexport type SanitizeFeedbackResult = { text: string; redactions: number };\n\nconst trimTrailingUrlPunctuation = (\n  match: string,\n): { core: string; trailing: string } => {\n  let core = match;\n  let trailing = \"\";\n\n  const sentencePunctuation =\n    URL_TRAILING_PUNCTUATION_REGEX.exec(core)?.[0] ?? \"\";\n  if (sentencePunctuation.length > 0) {\n    core = core.slice(0, -sentencePunctuation.length);\n    trailing = sentencePunctuation;\n  }\n\n  const pairs = [\n    { open: \"(\", close: \")\" },\n    { open: \"[\", close: \"]\" },\n    { open: \"{\", close: \"}\" },\n  ] as const;\n  let changed = true;\n  while (changed) {\n    changed = false;\n    for (const { close, open } of pairs) {\n      if (!core.endsWith(close)) {\n        continue;\n      }\n      const opens = Array.from(core).filter((char) => char === open).length;\n      const closes = Array.from(core).filter((char) => char === close).length;\n      if (closes <= opens) {\n        continue;\n      }\n      core = core.slice(0, -close.length);\n      trailing = `${close}${trailing}`;\n      changed = true;\n    }\n  }\n\n  return { core, trailing };\n};\n\n/**\n * Redact the well-known sensitive shapes from one feedback field. Returns the\n * cleaned text and the number of substitutions made (surfaced to the human so\n * they can judge how much was stripped). Each pass replaces with a bracketed\n * placeholder, so a downstream pass never re-matches an earlier placeholder.\n */\nexport const sanitizeFeedbackText = (input: string): SanitizeFeedbackResult => {\n  let redactions = 0;\n  const bump = (): void => {\n    redactions += 1;\n  };\n\n  let text = input;\n\n  text = text.replace(JWT_REGEX, () => {\n    bump();\n    return REDACTED_SECRET;\n  });\n  text = text.replace(HEX_SECRET_REGEX, () => {\n    bump();\n    return REDACTED_SECRET;\n  });\n  text = text.replace(BASE64_SECRET_REGEX, () => {\n    bump();\n    return REDACTED_SECRET;\n  });\n  text = text.replace(URL_REGEX, (match) => {\n    const { core, trailing } = trimTrailingUrlPunctuation(match);\n    let url: URL;\n    try {\n      url = new URL(core);\n    } catch {\n      // Not a parseable URL, so fail closed: the only preserved case is the\n      // positively-recognised public repo URL, which needs a successful parse.\n      bump();\n      return `${REDACTED_URL}${trailing}`;\n    }\n    if (isPreservedPublicUrl(url)) {\n      return match;\n    }\n    bump();\n    return `${REDACTED_URL}${trailing}`;\n  });\n  text = text.replace(EMAIL_REGEX, () => {\n    bump();\n    return REDACTED_EMAIL;\n  });\n  text = text.replace(UUID_REGEX, () => {\n    bump();\n    return REDACTED_ID;\n  });\n  text = text.replace(IPV4_REGEX, () => {\n    bump();\n    return REDACTED_IP;\n  });\n  text = text.replace(IPV6_REGEX, () => {\n    bump();\n    return REDACTED_IP;\n  });\n\n  return { text, redactions };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;AAkBA,MAAM,iBAAiB;AACvB,MAAM,cAAc;AACpB,MAAM,kBAAkB;AACxB,MAAM,eAAe;AACrB,MAAM,cAAc;AAEpB,MAAM,wBAAwB,QAC5B,IAAI,aAAa,MACjB,IAAI,aAAa,MACjB,IAAI,WAAW,MACf,IAAI,SAAS;;;;;;AAOf,MAAM,wBAAwB,QAAsB;CAClD,IAAI,CAAC,qBAAqB,GAAG,GAC3B,OAAO;CAET,IAAI,IAAI,SAAS,YAAY,MAAM,cACjC,OAAO;CAET,OACE,IAAI,aAAa,uBACjB,IAAI,SAAS,WAAW,oBAAoB;AAEhD;AAIA,MAAM,YACJ;AAGF,MAAM,mBAAmB;AAOzB,MAAM,sBAAsB;AAK5B,MAAM,YAAY;AAClB,MAAM,iCAAiC;AAEvC,MAAM,cAAc;AAEpB,MAAM,aACJ;AAEF,MAAM,aAAa;AAKnB,MAAM,aACJ;AAIF,MAAM,8BACJ,UACuC;CACvC,IAAI,OAAO;CACX,IAAI,WAAW;CAEf,MAAM,sBACJ,+BAA+B,KAAK,IAAI,CAAC,GAAG,MAAM;CACpD,IAAI,oBAAoB,SAAS,GAAG;EAClC,OAAO,KAAK,MAAM,GAAG,CAAC,oBAAoB,MAAM;EAChD,WAAW;CACb;CAEA,MAAM,QAAQ;EACZ;GAAE,MAAM;GAAK,OAAO;EAAI;EACxB;GAAE,MAAM;GAAK,OAAO;EAAI;EACxB;GAAE,MAAM;GAAK,OAAO;EAAI;CAC1B;CACA,IAAI,UAAU;CACd,OAAO,SAAS;EACd,UAAU;EACV,KAAK,MAAM,EAAE,OAAO,UAAU,OAAO;GACnC,IAAI,CAAC,KAAK,SAAS,KAAK,GACtB;GAEF,MAAM,QAAQ,MAAM,KAAK,IAAI,CAAC,CAAC,QAAQ,SAAS,SAAS,IAAI,CAAC,CAAC;GAE/D,IADe,MAAM,KAAK,IAAI,CAAC,CAAC,QAAQ,SAAS,SAAS,KAAK,CAAC,CAAC,UACnD,OACZ;GAEF,OAAO,KAAK,MAAM,GAAG,CAAC,MAAM,MAAM;GAClC,WAAW,GAAG,QAAQ;GACtB,UAAU;EACZ;CACF;CAEA,OAAO;EAAE;EAAM;CAAS;AAC1B;;;;;;;AAQA,MAAa,wBAAwB,UAA0C;CAC7E,IAAI,aAAa;CACjB,MAAM,aAAmB;EACvB,cAAc;CAChB;CAEA,IAAI,OAAO;CAEX,OAAO,KAAK,QAAQ,iBAAiB;EACnC,KAAK;EACL,OAAO;CACT,CAAC;CACD,OAAO,KAAK,QAAQ,wBAAwB;EAC1C,KAAK;EACL,OAAO;CACT,CAAC;CACD,OAAO,KAAK,QAAQ,2BAA2B;EAC7C,KAAK;EACL,OAAO;CACT,CAAC;CACD,OAAO,KAAK,QAAQ,YAAY,UAAU;EACxC,MAAM,EAAE,MAAM,aAAa,2BAA2B,KAAK;EAC3D,IAAI;EACJ,IAAI;GACF,MAAM,IAAI,IAAI,IAAI;EACpB,QAAQ;GAGN,KAAK;GACL,OAAO,GAAG,eAAe;EAC3B;EACA,IAAI,qBAAqB,GAAG,GAC1B,OAAO;EAET,KAAK;EACL,OAAO,GAAG,eAAe;CAC3B,CAAC;CACD,OAAO,KAAK,QAAQ,mBAAmB;EACrC,KAAK;EACL,OAAO;CACT,CAAC;CACD,OAAO,KAAK,QAAQ,kBAAkB;EACpC,KAAK;EACL,OAAO;CACT,CAAC;CACD,OAAO,KAAK,QAAQ,kBAAkB;EACpC,KAAK;EACL,OAAO;CACT,CAAC;CACD,OAAO,KAAK,QAAQ,kBAAkB;EACpC,KAAK;EACL,OAAO;CACT,CAAC;CAED,OAAO;EAAE;EAAM;CAAW;AAC5B"}