cli-jaw 2.2.8 → 2.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +4 -4
  2. package/dist/bin/agent-driven.js +33 -0
  3. package/dist/bin/agent-driven.js.map +1 -0
  4. package/dist/bin/commands/doctor.js +66 -1
  5. package/dist/bin/commands/doctor.js.map +1 -1
  6. package/dist/bin/commands/init.js +107 -8
  7. package/dist/bin/commands/init.js.map +1 -1
  8. package/dist/bin/interactive-confirm.js +110 -0
  9. package/dist/bin/interactive-confirm.js.map +1 -0
  10. package/dist/bin/star-prompt.js +46 -15
  11. package/dist/bin/star-prompt.js.map +1 -1
  12. package/dist/lib/upload.js +5 -1
  13. package/dist/lib/upload.js.map +1 -1
  14. package/dist/server.js +1 -0
  15. package/dist/server.js.map +1 -1
  16. package/dist/src/agent/alert-escalation.js +1 -1
  17. package/dist/src/agent/alert-escalation.js.map +1 -1
  18. package/dist/src/agent/codex-app-catalog.js +57 -0
  19. package/dist/src/agent/codex-app-catalog.js.map +1 -0
  20. package/dist/src/agent/codex-app-client.js +89 -7
  21. package/dist/src/agent/codex-app-client.js.map +1 -1
  22. package/dist/src/agent/pi-rpc-verdict.js +22 -0
  23. package/dist/src/agent/pi-rpc-verdict.js.map +1 -0
  24. package/dist/src/agent/pi-runtime.js +240 -0
  25. package/dist/src/agent/pi-runtime.js.map +1 -1
  26. package/dist/src/agent/runtime-pool.js +445 -0
  27. package/dist/src/agent/runtime-pool.js.map +1 -0
  28. package/dist/src/agent/spawn.js +395 -240
  29. package/dist/src/agent/spawn.js.map +1 -1
  30. package/dist/src/cli/command-context.js +5 -2
  31. package/dist/src/cli/command-context.js.map +1 -1
  32. package/dist/src/cli/commands.js +43 -39
  33. package/dist/src/cli/commands.js.map +1 -1
  34. package/dist/src/cli/handlers-runtime.js +15 -7
  35. package/dist/src/cli/handlers-runtime.js.map +1 -1
  36. package/dist/src/cli/handlers-search.js +1 -1
  37. package/dist/src/cli/handlers-search.js.map +1 -1
  38. package/dist/src/cli/handlers-skill-invoke.js +1 -1
  39. package/dist/src/cli/handlers-skill-invoke.js.map +1 -1
  40. package/dist/src/cli/handlers-workflows.js +1 -1
  41. package/dist/src/cli/handlers-workflows.js.map +1 -1
  42. package/dist/src/cli/handlers.js +1 -1
  43. package/dist/src/cli/handlers.js.map +1 -1
  44. package/dist/src/cli/opencodex-models.js +40 -7
  45. package/dist/src/cli/opencodex-models.js.map +1 -1
  46. package/dist/src/cli/registry-live.js +6 -5
  47. package/dist/src/cli/registry-live.js.map +1 -1
  48. package/dist/src/command-contract/catalog.js +4 -0
  49. package/dist/src/command-contract/catalog.js.map +1 -1
  50. package/dist/src/core/config.js +69 -4
  51. package/dist/src/core/config.js.map +1 -1
  52. package/dist/src/core/runtime-settings.js +17 -0
  53. package/dist/src/core/runtime-settings.js.map +1 -1
  54. package/dist/src/core/settings-merge.js +1 -1
  55. package/dist/src/core/settings-merge.js.map +1 -1
  56. package/dist/src/discord/bot.js +29 -14
  57. package/dist/src/discord/bot.js.map +1 -1
  58. package/dist/src/discord/commands.js +5 -4
  59. package/dist/src/discord/commands.js.map +1 -1
  60. package/dist/src/discord/discord-file.js +8 -2
  61. package/dist/src/discord/discord-file.js.map +1 -1
  62. package/dist/src/discord/forwarder.js +17 -19
  63. package/dist/src/discord/forwarder.js.map +1 -1
  64. package/dist/src/discord/send-only-client.js +9 -1
  65. package/dist/src/discord/send-only-client.js.map +1 -1
  66. package/dist/src/manager/dashboard-service.js +38 -1
  67. package/dist/src/manager/dashboard-service.js.map +1 -1
  68. package/dist/src/manager/telegram-hub/hub-bot.js +47 -29
  69. package/dist/src/manager/telegram-hub/hub-bot.js.map +1 -1
  70. package/dist/src/messaging/channel-health.js +32 -0
  71. package/dist/src/messaging/channel-health.js.map +1 -1
  72. package/dist/src/messaging/chunk.js +376 -0
  73. package/dist/src/messaging/chunk.js.map +1 -0
  74. package/dist/src/messaging/dedupe.js +104 -0
  75. package/dist/src/messaging/dedupe.js.map +1 -0
  76. package/dist/src/messaging/fold.js +210 -0
  77. package/dist/src/messaging/fold.js.map +1 -0
  78. package/dist/src/messaging/redact.js +577 -0
  79. package/dist/src/messaging/redact.js.map +1 -0
  80. package/dist/src/messaging/retry.js +76 -0
  81. package/dist/src/messaging/retry.js.map +1 -0
  82. package/dist/src/messaging/runtime.js +6 -3
  83. package/dist/src/messaging/runtime.js.map +1 -1
  84. package/dist/src/messaging/send.js +45 -15
  85. package/dist/src/messaging/send.js.map +1 -1
  86. package/dist/src/messaging/slack-target.js +50 -0
  87. package/dist/src/messaging/slack-target.js.map +1 -0
  88. package/dist/src/messaging/types.js.map +1 -1
  89. package/dist/src/orchestrator/pipeline.js +4 -4
  90. package/dist/src/orchestrator/pipeline.js.map +1 -1
  91. package/dist/src/routes/command.js +11 -7
  92. package/dist/src/routes/command.js.map +1 -1
  93. package/dist/src/routes/messaging.js +28 -12
  94. package/dist/src/routes/messaging.js.map +1 -1
  95. package/dist/src/routes/settings.js +22 -6
  96. package/dist/src/routes/settings.js.map +1 -1
  97. package/dist/src/slack/api.js +116 -0
  98. package/dist/src/slack/api.js.map +1 -0
  99. package/dist/src/slack/bot.js +262 -0
  100. package/dist/src/slack/bot.js.map +1 -0
  101. package/dist/src/slack/commands.js +98 -0
  102. package/dist/src/slack/commands.js.map +1 -0
  103. package/dist/src/slack/events.js +116 -0
  104. package/dist/src/slack/events.js.map +1 -0
  105. package/dist/src/slack/format.js +58 -0
  106. package/dist/src/slack/format.js.map +1 -0
  107. package/dist/src/slack/forwarder.js +57 -0
  108. package/dist/src/slack/forwarder.js.map +1 -0
  109. package/dist/src/slack/register.js +13 -0
  110. package/dist/src/slack/register.js.map +1 -0
  111. package/dist/src/slack/send-handler.js +41 -0
  112. package/dist/src/slack/send-handler.js.map +1 -0
  113. package/dist/src/slack/send-only-client.js +46 -0
  114. package/dist/src/slack/send-only-client.js.map +1 -0
  115. package/dist/src/slack/slack-file.js +72 -0
  116. package/dist/src/slack/slack-file.js.map +1 -0
  117. package/dist/src/slack/socket.js +341 -0
  118. package/dist/src/slack/socket.js.map +1 -0
  119. package/dist/src/telegram/bot.js +117 -37
  120. package/dist/src/telegram/bot.js.map +1 -1
  121. package/dist/src/telegram/elicitation-buttons.js +7 -2
  122. package/dist/src/telegram/elicitation-buttons.js.map +1 -1
  123. package/dist/src/telegram/forwarder.js +15 -7
  124. package/dist/src/telegram/forwarder.js.map +1 -1
  125. package/dist/src/telegram/rich-message.js +209 -24
  126. package/dist/src/telegram/rich-message.js.map +1 -1
  127. package/dist/src/telegram/telegram-file.js +35 -4
  128. package/dist/src/telegram/telegram-file.js.map +1 -1
  129. package/dist/src/telegram/voice.js +4 -3
  130. package/dist/src/telegram/voice.js.map +1 -1
  131. package/package.json +5 -1
  132. package/public/assets/providers/slack.svg +1 -0
  133. package/public/dist/assets/ChannelsDiscord-D9FLnZso.js +1 -0
  134. package/public/dist/assets/ChannelsSlack-ybvupgdK.js +1 -0
  135. package/public/dist/assets/ChannelsTelegram-BOCOPrBc.js +1 -0
  136. package/public/dist/assets/HealthBadge-megTlF4n.js +1 -0
  137. package/public/dist/assets/TransportStatusChips-CjEySMis.js +1 -0
  138. package/public/dist/assets/app-BGIAy4he.js +55 -0
  139. package/public/dist/assets/{bgtask-badge-CdBqBy1L.js → bgtask-badge-DXca8E7c.js} +1 -1
  140. package/public/dist/assets/bgtask-badge-GRSiNILQ.js +1 -0
  141. package/public/dist/assets/{employees-C8qufeJl.js → employees-BL4qMxJj.js} +1 -1
  142. package/public/dist/assets/iframe-renderer-BUSk0Rw9.js +1 -0
  143. package/public/dist/assets/{iframe-renderer-CZ67aeTO.js → iframe-renderer-DUI0LYBy.js} +2 -2
  144. package/public/dist/assets/{manager-CLXT60M5.js → manager-B-gpf-3j.js} +3 -3
  145. package/public/dist/assets/{memory-lWramLVw.js → memory-B_Y0L0KD.js} +1 -1
  146. package/public/dist/assets/memory-CX6YahTc.js +1 -0
  147. package/public/dist/assets/message-history-Det7u76S.js +1 -0
  148. package/public/dist/assets/{message-history-2NUN3UxM.js → message-history-Dl8dJzez.js} +1 -1
  149. package/public/dist/assets/{provider-icons-stsNmQKx.js → provider-icons-VDfahiUp.js} +3 -2
  150. package/public/dist/assets/{render-CsF-ojLe.js → render-BI1aqKoc.js} +1 -1
  151. package/public/dist/assets/{settings-BMLowRCh.js → settings-B0Sj4sUH.js} +1 -1
  152. package/public/dist/assets/settings-C7NutP6X.js +1 -0
  153. package/public/dist/assets/settings-core-CyX-px6_.js +100 -0
  154. package/public/dist/assets/settings-core-DVpFmrhY.js +1 -0
  155. package/public/dist/assets/{sidebar-gKjYLv49.js → sidebar-DOR9fTgN.js} +3 -3
  156. package/public/dist/assets/skills-BWhKreQ2.js +1 -0
  157. package/public/dist/assets/{skills-CKTbomdm.js → skills-wt0fkV9Y.js} +1 -1
  158. package/public/dist/assets/slash-commands-BZbqTg3v.js +20 -0
  159. package/public/dist/assets/slash-commands-Di4NDA4w.js +1 -0
  160. package/public/dist/assets/{trace-drawer-C3G4mmB1.js → trace-drawer-kMYzaVFW.js} +1 -1
  161. package/public/dist/assets/ui-BghexWrB.js +1 -0
  162. package/public/dist/assets/{ui-CVVC_flX.js → ui-D4D5HUfo.js} +1 -1
  163. package/public/dist/index.html +62 -1
  164. package/public/dist/manager/index.html +1 -1
  165. package/public/index.html +61 -0
  166. package/public/js/features/command-info.ts +1 -0
  167. package/public/js/features/help-content.ts +8 -0
  168. package/public/js/features/settings-channel.ts +5 -1
  169. package/public/js/features/settings-core.ts +2 -0
  170. package/public/js/features/settings-slack.ts +74 -0
  171. package/public/js/features/settings-types.ts +5 -1
  172. package/public/js/features/settings.ts +1 -0
  173. package/public/js/features/transport-status-row.ts +15 -3
  174. package/public/js/main.ts +19 -0
  175. package/public/js/provider-icons.ts +4 -1
  176. package/public/js/ws.ts +1 -1
  177. package/public/locales/en.json +21 -0
  178. package/public/locales/ja.json +21 -0
  179. package/public/locales/ko.json +21 -0
  180. package/public/locales/zh.json +21 -0
  181. package/public/manager/src/settings/SettingsShell.tsx +1 -0
  182. package/public/manager/src/settings/SettingsSidebar.tsx +1 -0
  183. package/public/manager/src/settings/pages/ChannelsSlack.tsx +323 -0
  184. package/public/manager/src/settings/pages/components/ActiveChannelToggle.tsx +2 -1
  185. package/public/manager/src/settings/pages/components/TransportStatusChips.tsx +13 -4
  186. package/public/manager/src/settings/types.ts +1 -0
  187. package/scripts/check-redaction-sinks.mjs +276 -0
  188. package/scripts/pi-rpc-probe.mts +361 -0
  189. package/scripts/release-gates.mjs +83 -0
  190. package/scripts/release-preview.sh +8 -1
  191. package/scripts/release.sh +13 -2
  192. package/scripts/sync-electron-version.cjs +93 -0
  193. package/public/dist/assets/ChannelsDiscord-Bt1_rtNC.js +0 -1
  194. package/public/dist/assets/ChannelsTelegram-BLV0m1oq.js +0 -1
  195. package/public/dist/assets/TransportStatusChips-Bld0yXi7.js +0 -1
  196. package/public/dist/assets/app-iSJhsP20.js +0 -55
  197. package/public/dist/assets/bgtask-badge-CSkSsoB6.js +0 -1
  198. package/public/dist/assets/iframe-renderer-DXdo0YXN.js +0 -1
  199. package/public/dist/assets/memory-DeKUlD2M.js +0 -1
  200. package/public/dist/assets/message-history-CWTQ2woA.js +0 -1
  201. package/public/dist/assets/settings-CDjs7hTT.js +0 -1
  202. package/public/dist/assets/settings-core-Cs_oq-b3.js +0 -99
  203. package/public/dist/assets/settings-core-D-TOh1a6.js +0 -1
  204. package/public/dist/assets/skills-GuIfNASs.js +0 -1
  205. package/public/dist/assets/slash-commands-BtZa093n.js +0 -20
  206. package/public/dist/assets/slash-commands-DGJKfpMT.js +0 -1
  207. package/public/dist/assets/ui-DJg5Q_NJ.js +0 -1
  208. /package/public/dist/assets/{DashboardMeta-B7CVqXcU.js → DashboardMeta-DennaaA_.js} +0 -0
  209. /package/public/dist/assets/{Display-BF-rACK6.js → Display-hGuSn_W5.js} +0 -0
  210. /package/public/dist/assets/{Employees-D3hnciP0.js → Employees-B5YXk5ma.js} +0 -0
  211. /package/public/dist/assets/{Heartbeat-D4Pn5O3O.js → Heartbeat-vDGhPfct.js} +0 -0
  212. /package/public/dist/assets/{Mcp-SfytKWGX.js → Mcp-BHWnJ6SP.js} +0 -0
  213. /package/public/dist/assets/{Memory-DNxBOQ7N.js → Memory-B_UZ-iCA.js} +0 -0
  214. /package/public/dist/assets/{ModelProvider-CXwtlG7l.js → ModelProvider-CgUvs6pE.js} +0 -0
  215. /package/public/dist/assets/{Network-DISUhige.js → Network-AWpSkALD.js} +0 -0
  216. /package/public/dist/assets/{Profile-B8yBO3Ju.js → Profile-DnIE2ios.js} +0 -0
  217. /package/public/dist/assets/{Prompts-5lNQ5qOm.js → Prompts-B9aKLall.js} +0 -0
  218. /package/public/dist/assets/{SpeechKeys-BepaIHBa.js → SpeechKeys-K4Be_o7z.js} +0 -0
  219. /package/public/dist/assets/{TelegramHub--DNjRAwW.js → TelegramHub-CFwSkzHP.js} +0 -0
  220. /package/public/dist/assets/{TerminalPanel-DQovYfwz.js → TerminalPanel-CdlFZHAK.js} +0 -0
@@ -9,9 +9,10 @@ export type TransportStatus = {
9
9
  };
10
10
 
11
11
  export type ChannelHealth = {
12
- activeInbound: 'telegram' | 'discord';
12
+ activeInbound: 'telegram' | 'discord' | 'slack';
13
13
  telegram: TransportStatus;
14
14
  discord: TransportStatus;
15
+ slack: TransportStatus;
15
16
  };
16
17
 
17
18
  function isTransportStatus(value: unknown): value is TransportStatus {
@@ -27,12 +28,20 @@ export function parseChannelHealth(payload: unknown): ChannelHealth | null {
27
28
  const channels = (payload as { channels?: unknown }).channels;
28
29
  if (!channels || typeof channels !== 'object') return null;
29
30
  const row = channels as Record<string, unknown>;
30
- if (row['activeInbound'] !== 'telegram' && row['activeInbound'] !== 'discord') return null;
31
+ const active = row['activeInbound'];
32
+ if (active !== 'telegram' && active !== 'discord' && active !== 'slack') return null;
31
33
  if (!isTransportStatus(row['telegram']) || !isTransportStatus(row['discord'])) return null;
34
+ // Slack is tolerated as absent: a newer Manager can be pointed at an older
35
+ // running cli-jaw, and rejecting the whole payload would hide Telegram and
36
+ // Discord health too.
37
+ const slack = isTransportStatus(row['slack'])
38
+ ? row['slack']
39
+ : { configured: false, activeInbound: false, sendCapable: false, reason: 'unavailable' };
32
40
  return {
33
- activeInbound: row['activeInbound'],
41
+ activeInbound: active,
34
42
  telegram: row['telegram'],
35
43
  discord: row['discord'],
44
+ slack,
36
45
  };
37
46
  }
38
47
 
@@ -46,7 +55,7 @@ export function transportChipLabels(status: TransportStatus): string[] {
46
55
 
47
56
  type Props = {
48
57
  client: SettingsClient;
49
- channel: 'telegram' | 'discord';
58
+ channel: 'telegram' | 'discord' | 'slack';
50
59
  };
51
60
 
52
61
  export function TransportStatusChips({ client, channel }: Props) {
@@ -7,6 +7,7 @@ export type SettingsCategoryId =
7
7
  | 'model'
8
8
  | 'channels-telegram'
9
9
  | 'channels-discord'
10
+ | 'channels-slack'
10
11
  | 'speech'
11
12
  | 'heartbeat'
12
13
  | 'memory'
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fail when a channel sink reaches a user or a log without masking.
4
+ *
5
+ * Written because the audits kept finding the same class of miss: masking was
6
+ * added at call sites, a new call site appeared, and every green test suite
7
+ * still said the channels were covered. A grep proves nothing on its own, but
8
+ * it is a good alarm for "somebody added a reply that skips the masker", which
9
+ * is precisely what kept happening.
10
+ *
11
+ * The allowlist is the honest part. An exception has to name the line and the
12
+ * reason; an entry without one is a bug waiting to be found again.
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+
20
+ /**
21
+ * Directories and single files whose outbound and logging surfaces must be masked.
22
+ *
23
+ * The channel folders are the obvious part. `src/messaging` is the shared layer
24
+ * every channel routes through, and `src/routes/messaging.ts` holds a Telegram
25
+ * `api.sendMessage` that lives outside the channel folders — the first version
26
+ * of this gate did not watch it, so a future edit there could have dropped the
27
+ * mask with no gate to notice.
28
+ */
29
+ const SCANNED = [
30
+ 'src/telegram',
31
+ 'src/discord',
32
+ 'src/slack',
33
+ 'src/manager/telegram-hub',
34
+ 'src/messaging',
35
+ 'src/routes/messaging.ts',
36
+ ];
37
+
38
+ /** Calls that put text in front of a user, or into a log file. */
39
+ const SINK_PATTERNS = [
40
+ { name: 'chat reply', re: /\b(?:ctx\.reply|msg\.reply|channel\.send|editReply|answerCallbackQuery)\s*\(/ },
41
+ { name: 'vendor send', re: /\bapi\.(?:sendMessage|editMessageText)\s*\(/ },
42
+ { name: 'logger', re: /\b(?:log|appLog|console)\.(?:error|warn|info)\s*\(/ },
43
+ ];
44
+
45
+ /** A line counts as handled when it routes through one of these. */
46
+ const MASKERS = /\b(?:redactOutboundText|redactOutboundPayload|redactChannelSecrets|userErrorText|logErrorText)\s*\(/;
47
+
48
+ /**
49
+ * Lines that are safe without a masker, each with its reason.
50
+ *
51
+ * Matched on a distinctive substring rather than a line number, so ordinary
52
+ * edits above do not silently shift an exception onto a different line.
53
+ */
54
+ const ALLOWLIST = [
55
+ // Fixed operator strings. The variable in them is a count, a limit or an
56
+ // id, none of which a credential travels in.
57
+ { contains: 'Disabling Discord for this session', why: 'fixed string' },
58
+ { contains: 'agent busy, queued', why: 'a pending count' },
59
+ { contains: 'Max retries', why: 'a retry limit constant' },
60
+ { contains: 'exceeds cap, giving up', why: 'elapsed milliseconds' },
61
+ { contains: 'max reconnect attempts', why: 'an attempt limit constant' },
62
+ { contains: 'the bot identity could not be read', why: 'fixed string' },
63
+ { contains: 'channel not sendable', why: 'a channel id' },
64
+ { contains: 'attempt ${attempt}/${MAX_RETRIES} failed', why: 'an attempt count and a vendor error code' },
65
+ // Vendor status text: a documented enum value such as invalid_auth or
66
+ // channel_not_found, not a message body.
67
+ { contains: "auth.test failed:", why: 'a Slack error code' },
68
+ { contains: 'apps.connections.open failed:', why: 'a Slack error code' },
69
+ { contains: 'ack failed', why: 'a Slack error code' },
70
+ // Already masked by the channel-local helper that predates the shared one.
71
+ { contains: 'redactSlackTokens(', why: 'masked by the Slack alias of the shared redactor' },
72
+ // Chunks and previews built from text the caller already masked.
73
+ { contains: 'channel.send(chunk)', why: 'chunks come from chunkDiscordMessage, which masks' },
74
+ { contains: '[discord:forward] → ${channelId}', why: 'preview is built from the masked text' },
75
+ { contains: '[tg:forward] → chat ${chatId}', why: 'preview is built from the masked text' },
76
+ // Refusal notices whose reason is one of our own constants.
77
+ { contains: '❌ ${result.reason}', why: 'reason is a fixed refusal string from the gateway' },
78
+ { contains: 'msg.reply(warning)', why: 'warning is assembled from fixed attachment-failure strings' },
79
+ // Hub notices with no interpolation, and acks masked at their source.
80
+ { contains: 'Topic not connected', why: 'fixed string' },
81
+ { contains: 'Instance error', why: 'fixed string' },
82
+ { contains: 'This topic is not connected', why: 'fixed string' },
83
+ { contains: '이 토픽은 인스턴스에 연결되지', why: 'fixed string' },
84
+ { contains: 'ctx.answerCallbackQuery(ack', why: 'ack is masked where it is built' },
85
+ { contains: 'ctx.reply(syncText)', why: 'syncText is masked where it is built' },
86
+ { contains: 'await ctx.reply(syncText)', why: 'syncText is masked where it is built' },
87
+ { contains: 'Chat ID: <code>', why: 'a chat id the user just asked for' },
88
+ ];
89
+
90
+ /**
91
+ * Variables assigned from a masker earlier in the same file.
92
+ *
93
+ * `const out = redactOutboundText(...)` then `ctx.reply(out)` is correct, and
94
+ * flagging it teaches people to ignore the gate. This is a file-scoped
95
+ * approximation, not dataflow — it will miss a variable reassigned to
96
+ * something unsafe, which is a trade for a gate that stays believable.
97
+ */
98
+ function maskedLocals(source) {
99
+ const names = new Set();
100
+ const re = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?(?:redactOutboundText|redactOutboundPayload|redactChannelSecrets|userErrorText|logErrorText)\s*\(/g;
101
+ let m;
102
+ while ((m = re.exec(source)) !== null) names.add(m[1]);
103
+ return names;
104
+ }
105
+
106
+ function listSources(dir) {
107
+ const abs = path.join(repoRoot, dir);
108
+ if (!fs.existsSync(abs)) return [];
109
+ if (fs.statSync(abs).isFile()) return [dir];
110
+ return fs.readdirSync(abs)
111
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.d.ts'))
112
+ .map((f) => path.join(dir, f));
113
+ }
114
+
115
+ /**
116
+ * Whether the line carries a value that could hold a credential.
117
+ *
118
+ * The first version flagged 62 lines, nearly all of them chat ids, counts and
119
+ * i18n lookups. A gate that cries wolf gets ignored, which is worse than not
120
+ * having one, so this asks the narrower question: does the line move text that
121
+ * came from an agent, a vendor, or a user?
122
+ *
123
+ * Those are the three sources a token has ever arrived from in this codebase.
124
+ * Structural values — ids, counts, filenames, translated strings — are not
125
+ * flagged, and a real leak through one of them would be a new class worth
126
+ * finding by other means.
127
+ */
128
+ function carriesUntrustedText(line) {
129
+ return /\b(?:text|caption|content|message|body|result|output|reply|answer|prompt|preview|transcript|err|error|e)\b/.test(line)
130
+ // A translated string is built from a catalogue, not from input.
131
+ && !/\bt\(['"]/.test(line);
132
+ }
133
+
134
+ export function findUnmaskedSinks() {
135
+ const findings = [];
136
+ for (const dir of SCANNED) {
137
+ for (const rel of listSources(dir)) {
138
+ const source = fs.readFileSync(path.join(repoRoot, rel), 'utf8');
139
+ const masked = maskedLocals(source);
140
+ const lines = source.split('\n');
141
+ lines.forEach((line, i) => {
142
+ const trimmed = line.trim();
143
+ if (trimmed.startsWith('//') || trimmed.startsWith('*')) return;
144
+ const sink = SINK_PATTERNS.find((p) => p.re.test(line));
145
+ if (!sink) return;
146
+ if (MASKERS.test(line)) return;
147
+ if ([...masked].some((name) => new RegExp(`\\b${name}\\b`).test(line))) return;
148
+ if (!carriesUntrustedText(line)) return;
149
+ if (ALLOWLIST.some((a) => line.includes(a.contains))) return;
150
+ findings.push({ file: rel, line: i + 1, kind: sink.name, text: trimmed.slice(0, 100) });
151
+ });
152
+ }
153
+ }
154
+ return findings;
155
+ }
156
+
157
+ /**
158
+ * Functions every outbound text of a channel must pass through, and which
159
+ * therefore have to keep calling a masker.
160
+ *
161
+ * Most call sites are allowlisted on the grounds that "the chunker masks", and
162
+ * for a while nothing checked that the chunker still did. Deleting the
163
+ * `redactOutboundText(...)` inside `chunkDiscordMessage` unmasked every Discord
164
+ * send in the codebase and the gate stayed green, because each individual call
165
+ * site still looked exactly as approved.
166
+ *
167
+ * These are the funnels. If one stops masking, the allowlist entries that lean
168
+ * on it become false, so the funnel is checked directly.
169
+ */
170
+ const REQUIRED_MASKING_FUNNELS = [
171
+ { file: 'src/discord/forwarder.ts', fn: 'chunkDiscordMessage', why: 'every outbound Discord text is chunked here' },
172
+ { file: 'src/slack/format.ts', fn: 'chunkSlackMessage', why: 'every outbound Slack text is chunked here' },
173
+ { file: 'src/telegram/rich-message.ts', fn: 'sendTelegramMarkdown', why: 'the single entry point for Telegram rich sends' },
174
+ // `sendTelegramFile` masks two independent things: the caption on the way
175
+ // out, and the vendor error on the way back. Checking only that the body
176
+ // mentions SOME masker lets the caption mask be deleted while the error
177
+ // mask keeps the gate green, so this one names the expression.
178
+ {
179
+ file: 'src/telegram/telegram-file.ts',
180
+ fn: 'sendTelegramFile',
181
+ expect: /caption\s*=\s*opts\?\.caption\s*===\s*undefined\s*\?\s*undefined\s*:\s*redactOutboundText\(/,
182
+ why: 'a caption reaches the room like a message body',
183
+ },
184
+ ];
185
+
186
+ /**
187
+ * Body of a top-level function declaration, by brace balance.
188
+ *
189
+ * Finding the body is the fiddly part: the first `{` after the name belongs to
190
+ * an inline parameter type (`opts?: { caption?: string }`), and the second to a
191
+ * return type (`Promise<{ ok: boolean }>`). So the parameter list is skipped by
192
+ * paren depth, and the return annotation by angle depth, leaving the real body
193
+ * brace. A parser would be more correct and would also make this gate something
194
+ * people stop reading.
195
+ */
196
+ function functionBody(source, fn) {
197
+ const start = source.search(new RegExp(`(?:export\\s+)?(?:async\\s+)?function\\s+${fn}\\s*[(<]`));
198
+ if (start === -1) return null;
199
+ // Step 1: walk the parameter list to its closing paren.
200
+ const paren = source.indexOf('(', start);
201
+ if (paren === -1) return null;
202
+ let parenDepth = 0;
203
+ let afterParams = -1;
204
+ for (let i = paren; i < source.length; i += 1) {
205
+ if (source[i] === '(') parenDepth += 1;
206
+ else if (source[i] === ')') {
207
+ parenDepth -= 1;
208
+ if (parenDepth === 0) { afterParams = i + 1; break; }
209
+ }
210
+ }
211
+ if (afterParams === -1) return null;
212
+ // Step 2: the body brace is the first `{` outside any return-type generic.
213
+ let angle = 0;
214
+ let open = -1;
215
+ for (let i = afterParams; i < source.length; i += 1) {
216
+ const c = source[i];
217
+ if (c === '<') angle += 1;
218
+ else if (c === '>') angle = Math.max(0, angle - 1);
219
+ else if (c === '{' && angle === 0) { open = i; break; }
220
+ }
221
+ if (open === -1) return null;
222
+ let depth = 0;
223
+ for (let i = open; i < source.length; i += 1) {
224
+ if (source[i] === '{') depth += 1;
225
+ else if (source[i] === '}') {
226
+ depth -= 1;
227
+ if (depth === 0) return source.slice(open, i + 1);
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+
233
+ export function findUnmaskedFunnels() {
234
+ const findings = [];
235
+ for (const funnel of REQUIRED_MASKING_FUNNELS) {
236
+ const abs = path.join(repoRoot, funnel.file);
237
+ if (!fs.existsSync(abs)) {
238
+ findings.push({ ...funnel, detail: 'file is gone — the funnel moved and this gate was not updated' });
239
+ continue;
240
+ }
241
+ const body = functionBody(fs.readFileSync(abs, 'utf8'), funnel.fn);
242
+ if (body === null) {
243
+ findings.push({ ...funnel, detail: 'function not found — it was renamed or removed' });
244
+ continue;
245
+ }
246
+ const required = funnel.expect ?? MASKERS;
247
+ if (!required.test(body)) {
248
+ findings.push({
249
+ ...funnel,
250
+ detail: funnel.expect ? 'the specific masking expression is gone' : 'body no longer calls a masker',
251
+ });
252
+ }
253
+ }
254
+ return findings;
255
+ }
256
+
257
+ if (import.meta.url === `file://${process.argv[1]}`) {
258
+ const findings = findUnmaskedSinks();
259
+ const funnels = findUnmaskedFunnels();
260
+ if (findings.length === 0 && funnels.length === 0) {
261
+ console.log('✅ redaction sinks — every channel reply, send and logger routes through a masker');
262
+ process.exit(0);
263
+ }
264
+ if (findings.length > 0) {
265
+ console.error(`❌ redaction sinks — ${findings.length} unmasked:`);
266
+ for (const f of findings) console.error(` ${f.file}:${f.line} (${f.kind}) ${f.text}`);
267
+ console.error('\nEither route it through a masker, or add an allowlist entry WITH a reason.');
268
+ }
269
+ if (funnels.length > 0) {
270
+ console.error(`❌ masking funnels — ${funnels.length} broken:`);
271
+ for (const f of funnels) console.error(` ${f.file} ${f.fn}(): ${f.detail}\n ${f.why}`);
272
+ console.error('\nAllowlist entries below assume these funnels mask. Restore the mask, or');
273
+ console.error('move the funnel and update REQUIRED_MASKING_FUNNELS to match.');
274
+ }
275
+ process.exit(1);
276
+ }