@cocorograph/hub-agent 0.8.64 → 0.8.66

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.
@@ -8,23 +8,20 @@
8
8
  * jsonl へ流れていた。さらに Hub ナレッジに格納した **非公開スキルプロンプト本文**
9
9
  * (`get_skill_prompt` MCP の戻り値) も毎回リザルトに露出していた。
10
10
  *
11
- * 本モジュールは「表示・配信・記録されるコピー」だけをマスクする。モデルが実際に受け取る
12
- * 入力は SDK stream の上流 (Claude CLI) で確定しており、hub-agent に届くのは **モデルが
13
- * 消費した後** のイベントなので、ここでマスクしてもモデルの動作・スキル実行は壊れない
14
- * (詳細は claude-stream-bridge.mjs の emit 経路コメント参照)。
11
+ * ライブ表示は SDK / app-server のイベントのコピーをマスクし、実行中のモデル入力を変更しない。
12
+ * Claude の終了後 JSONL 書き換えは従来の対象・正規表現を別 helper で維持する。再開時にはマスク済みの
13
+ * 内容が読まれる。Codex の再開用 rollout 原本には適用しない。
15
14
  *
16
15
  * 2 種類のマスク:
17
16
  * - 秘匿情報: 正規表現で既知トークン形 / sensitive な KEY=VALUE 代入を伏せ字化する。
18
- * モデルは元々不要なので、オンディスク jsonl をマスクしても resume は無傷。
17
+ * 表示コピーでは JSON の秘密フィールドにも適用する。保存用の対象は拡大しない。
19
18
  * - スキルプロンプト本文: `get_skill_prompt` (MCP 名前空間込み) の tool_result 本文を
20
19
  * まるごとプレースホルダへ置換する。tool_use → tool_result の id 対応が必要なため、
21
20
  * stream にはステートフルな masker (createEventMasker)、jsonl 一括には 1 パスの
22
21
  * maskJsonlText を用意する。
23
22
  *
24
- * ⚠️ 限定事項 (v1): includePartialMessages の **部分デルタ (stream_event)** はマスク
25
- * しない。秘匿情報の主出所である tool_result / .env ダンプは完成済みメッセージとして
26
- * 届くためここで捕捉でき、最終メッセージ・jsonl・hydrate も全てマスクされる。アシスタント
27
- * が逐次生成する途中テキストにシークレットが現れる確率は低いと判断し v1 では対象外。
23
+ * 部分デルタは claude-display-mask / codex-result-mask が表示前に抑止し、
24
+ * 分割された秘密値を連結・表示しない。本文は完成済みメッセージから表示する。
28
25
  */
29
26
  import { readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises"
30
27
 
@@ -54,12 +51,14 @@ export const DEPLOY_CRED_PLACEHOLDER =
54
51
  * 既知の秘匿情報パターン (誤検知を抑えるため、形が明確なトークンに限定する)。
55
52
  * 各要素は global フラグ付き RegExp。replace で SECRET_PLACEHOLDER に置換する。
56
53
  */
54
+ const DISPLAY_OPENAI_PATTERN = /\bsk-(?:(?:proj|svcacct)-)?[A-Za-z0-9_-]{20,}/g
55
+ const AT_REST_OPENAI_PATTERN = /sk-[A-Za-z0-9]{20,}/g
57
56
  const SECRET_PATTERNS = [
58
57
  // PEM 秘密鍵ブロック (複数行)。RSA/EC/OPENSSH/DSA/PGP の修飾子に対応。
59
58
  /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
60
59
  // Anthropic / OpenAI 系 API キー (sk-ant-... を先に置換するため先頭に)。
61
60
  /sk-ant-[A-Za-z0-9_-]{20,}/g,
62
- /sk-[A-Za-z0-9]{20,}/g,
61
+ DISPLAY_OPENAI_PATTERN,
63
62
  // AWS アクセスキー ID。
64
63
  /\bAKIA[0-9A-Z]{16}\b/g,
65
64
  // GitHub トークン (ghp_/gho_/ghu_/ghs_/ghr_ と fine-grained PAT)。
@@ -79,10 +78,16 @@ const SECRET_PATTERNS = [
79
78
  /**
80
79
  * sensitive な名前の KEY=VALUE / KEY: VALUE 代入を伏せ字化する。キー名は残し値だけ消す
81
80
  * (例: `DB_PASSWORD=hunter2` → `DB_PASSWORD=[REDACTED]`)。.env ダンプ / 環境変数出力対策。
82
- * 値は 4 文字以上のとき対象 (短い値の誤検知回避)。引用符は維持する。
81
+ * キーの完全な構成要素で判定し、短い値も対象にする。引用符は維持する。
83
82
  */
84
83
  const ASSIGN_PATTERN =
85
- /\b([A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|AUTH[_-]?TOKEN)[A-Za-z0-9_]*)(\s*[=:]\s*)(['"]?)([^\s'"]{4,})\3/gi
84
+ /(["']?)\b([A-Za-z_][A-Za-z0-9_-]*)\1(\s*[=:]\s*)("(?:\\.|[^"\\])*"|'[^']*'|\[REDACTED\]|[^\s,;{}\[\]"']+|["'][^\r\n]*)/g
85
+
86
+ // Match complete key components, not substrings such as tokenUsage / tokenizer.
87
+ function isSecretKey(key) {
88
+ const normalized = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/-/g, "_").toLowerCase()
89
+ return /(?:^|_)(?:password|passwd|secret|token|api_key|access_key|private_key|client_secret|authorization|cookie|set_cookie)$/.test(normalized)
90
+ }
86
91
 
87
92
  /**
88
93
  * 文字列中の秘匿情報を伏せ字化する。マスク対象が無ければ同一文字列をそのまま返す。
@@ -91,17 +96,113 @@ const ASSIGN_PATTERN =
91
96
  */
92
97
  export function redactSecrets(text) {
93
98
  if (typeof text !== "string" || text.length === 0) return text
99
+ // Decode JSON (including JSON encoded inside a JSON string) before inspecting
100
+ // keys. Keep the original bytes when no redaction is needed.
101
+ if (/^\s*[\[{\"]/.test(text)) {
102
+ try {
103
+ const parsed = JSON.parse(text)
104
+ const masked = redactSecretsValue(parsed)
105
+ return masked !== parsed ? JSON.stringify(masked) : text
106
+ } catch { /* Plain text and incomplete JSON use the patterns below. */ }
107
+ }
94
108
  let out = text
95
109
  for (const re of SECRET_PATTERNS) {
96
110
  out = out.replace(re, SECRET_PLACEHOLDER)
97
111
  }
112
+ out = out.replace(/\b(Authorization\s*:\s*)(?:Bearer|Basic)\s+[^\s,"'}]+/gi, `$1${SECRET_PLACEHOLDER}`)
98
113
  out = out.replace(
99
114
  ASSIGN_PATTERN,
100
- (_m, key, sep, quote, _val) => `${key}${sep}${quote}${SECRET_PLACEHOLDER}${quote}`,
115
+ (match, keyQuote, key, sep, value) => {
116
+ if (!isSecretKey(key)) return match
117
+ const quote = /^["']/.test(value) ? value[0] : ""
118
+ return `${keyQuote}${key}${keyQuote}${sep}${quote}${SECRET_PLACEHOLDER}${quote}`
119
+ },
101
120
  )
121
+ out = out.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]+@/gi, `$1${SECRET_PLACEHOLDER}@`)
102
122
  return out
103
123
  }
104
124
 
125
+ /** Recursively sanitize a display copy; unchanged branches retain identity. */
126
+ export function redactSecretsValue(value) {
127
+ if (typeof value === "string") return redactSecrets(value)
128
+ if (!value || typeof value !== "object") return value
129
+ let changed = false
130
+ const keys = new Set()
131
+ let collision = false
132
+ const entries = Object.entries(value).map(([key, original]) => {
133
+ const safeKey = Array.isArray(value) ? key : redactSecrets(key)
134
+ if (keys.has(safeKey)) collision = true
135
+ keys.add(safeKey)
136
+ const presenceFlag = typeof original === "boolean" && /^has(?:_|[A-Z])/.test(key)
137
+ const masked = !Array.isArray(value) && !presenceFlag && isSecretKey(key)
138
+ ? SECRET_PLACEHOLDER : redactSecretsValue(original)
139
+ if (masked !== original || safeKey !== key) changed = true
140
+ return [safeKey, masked]
141
+ })
142
+ if (collision) return SECRET_PLACEHOLDER
143
+ if (!changed) return value
144
+ return Array.isArray(value) ? entries.map(([, item]) => item) : Object.fromEntries(entries)
145
+ }
146
+
147
+ /** Only recognize a real, explicit helper invocation, not echo/documentation. */
148
+ function isDeployHelperCommand(command, allowShellWrapper = true) {
149
+ if (typeof command !== "string") return false
150
+ const tokens = command.match(/"(?:\\.|[^"\\])*"|'[^']*'|&&|\|\||[;\n|]|[^\s;&|]+/g) ?? []
151
+ let start = 0
152
+ for (let end = 0; end <= tokens.length; end++) {
153
+ if (end < tokens.length && !/^(?:&&|\|\||[;\n|])$/.test(tokens[end])) continue
154
+ const words = tokens.slice(start, end).map((word) => word.replace(/^(["'])([\s\S]*)\1$/, "$2"))
155
+ start = end + 1
156
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? "")) words.shift()
157
+ // Unwrap one explicit shell command only; do not evaluate arbitrary shell code.
158
+ if (allowShellWrapper && /(?:^|\/)(?:bash|sh|zsh)$/.test(words[0] ?? "")
159
+ && /^-(?:c|lc|cl)$/.test(words[1] ?? "") && words.length === 3
160
+ && isDeployHelperCommand(words[2], false)) return true
161
+ if (/(?:^|\/)python(?:3(?:\.\d+)?)?$/.test(words[0] ?? "")) {
162
+ words.shift()
163
+ while (/^-(?:u|B)$/.test(words[0] ?? "")) words.shift()
164
+ }
165
+ if (/(?:^|\/)hub_helper\.py$/.test(words[0] ?? "") && words[1] === "deploy_creds") return true
166
+ }
167
+ return false
168
+ }
169
+
170
+ /** Shared Claude/Codex policy. Input is consulted before it is redacted. */
171
+ export function toolResultPlaceholder(name, input) {
172
+ if (isSkillPromptTool(name)) return SKILL_PROMPT_PLACEHOLDER
173
+ if (isDeployCredentialTool(name)) return DEPLOY_CRED_PLACEHOLDER
174
+ const shortName = typeof name === "string" ? name.split(/__|\./).at(-1) : ""
175
+ if (shortName === "exec") {
176
+ const source = typeof input === "string" ? input : input?.code ?? input?.source
177
+ // Code-mode aggregates inner results without a reliable per-result tool ID.
178
+ // A visible reference to a protected tool therefore makes the entire wrapper
179
+ // result private, including other results from that same execution. This is
180
+ // deliberately conservative (also covers bracket access/aliases), not a JS
181
+ // interpreter or a claim to detect dynamically constructed tool names.
182
+ if (typeof source === "string") {
183
+ if (/(?:\b|__)get_skill_prompt\b/.test(source)
184
+ || (/(?:\b|__)(?:get_knowledge_page|get_session_log)\b/.test(source)
185
+ && /(?:skill|エージェント)\//.test(source))) return SKILL_PROMPT_PLACEHOLDER
186
+ if (/(?:\b|__)(?:get_deploy_target|deploy_creds)\b/.test(source)) return DEPLOY_CRED_PLACEHOLDER
187
+ }
188
+ }
189
+ if (["get_knowledge_page", "get_session_log"].includes(shortName)
190
+ && typeof input?.slug === "string" && /^(?:skill|エージェント)\//.test(input.slug)) {
191
+ return SKILL_PROMPT_PLACEHOLDER
192
+ }
193
+ if (["Bash", "exec_command"].includes(shortName)
194
+ && isDeployHelperCommand(input?.command ?? input?.cmd)) return DEPLOY_CRED_PLACEHOLDER
195
+ return null
196
+ }
197
+
198
+ function classifiedToolName(block) {
199
+ if (isSkillPromptTool(block.name) || isDeployCredentialTool(block.name)) return block.name
200
+ const placeholder = toolResultPlaceholder(block.name, block.input)
201
+ if (placeholder === SKILL_PROMPT_PLACEHOLDER) return "get_skill_prompt"
202
+ if (placeholder === DEPLOY_CRED_PLACEHOLDER) return "deploy_creds"
203
+ return block.name
204
+ }
205
+
105
206
  /**
106
207
  * tool 名が非公開スキルプロンプト取得ツールか判定する。MCP 名前空間が付くため
107
208
  * (`mcp__hub__get_skill_prompt` / `mcp__hub_staff__get_skill_prompt`)、末尾一致でも拾う。
@@ -132,28 +233,6 @@ export function isDeployCredentialTool(name) {
132
233
  )
133
234
  }
134
235
 
135
- /** tool_result の content (string | block[]) をプレースホルダ 1 本へ潰す。 */
136
- function skillPlaceholderContent() {
137
- return [{ type: "text", text: SKILL_PROMPT_PLACEHOLDER }]
138
- }
139
-
140
- /** tool_result の content をデプロイ接続情報プレースホルダ 1 本へ潰す。 */
141
- function deployCredPlaceholderContent() {
142
- return [{ type: "text", text: DEPLOY_CRED_PLACEHOLDER }]
143
- }
144
-
145
- /** content blocks 配列の text を redactSecrets で処理した新配列を返す (非破壊)。 */
146
- function redactBlocks(blocks) {
147
- if (!Array.isArray(blocks)) return blocks
148
- return blocks.map((b) => {
149
- if (b && b.type === "text" && typeof b.text === "string") {
150
- const masked = redactSecrets(b.text)
151
- return masked === b.text ? b : { ...b, text: masked }
152
- }
153
- return b
154
- })
155
- }
156
-
157
236
  /**
158
237
  * SDK message 形 (jsonl 1 行 / live event) のオブジェクトをマスクした **コピー** を返す。
159
238
  * 変更が無ければ元オブジェクトをそのまま返す (参照同一で「変化なし」を呼び出し側が判定可)。
@@ -165,104 +244,33 @@ function redactBlocks(blocks) {
165
244
  export function maskMessageObject(obj, toolNames) {
166
245
  if (!MASK_ENABLED) return obj
167
246
  if (!obj || typeof obj !== "object") return obj
168
-
169
- if (obj.type === "system" && obj.subtype === "codex_delegate") {
170
- let changed = false
171
- const next = { ...obj }
172
- for (const field of ["prompt", "description"]) {
173
- if (typeof obj[field] !== "string") continue
174
- const masked = redactSecrets(obj[field])
175
- if (masked !== obj[field]) {
176
- next[field] = masked
177
- changed = true
178
- }
179
- }
180
- return changed ? next : obj
247
+ collectToolUseNames(obj, toolNames)
248
+ obj = redactSecretsValue(obj)
249
+ // Some SDK/user envelopes duplicate tool output outside message.content.
250
+ if (obj.type === "user" && Object.hasOwn(obj, "tool_use_result") && Array.isArray(obj.message?.content)) {
251
+ const placeholder = obj.message.content.reduce((found, block) => found ||
252
+ (block?.type === "tool_result" ? toolResultPlaceholder(toolNames.get(block.tool_use_id)) : null), null)
253
+ if (placeholder && obj.tool_use_result !== placeholder) obj = { ...obj, tool_use_result: placeholder }
181
254
  }
182
255
 
183
256
  const message = obj.message
184
- if (!message || typeof message !== "object") return obj
185
- const content = message.content
186
-
187
- // assistant: tool_use の id→name を記録しつつ text ブロックの秘匿情報を伏せる。
188
- if (obj.type === "assistant") {
189
- if (Array.isArray(content)) {
190
- for (const b of content) {
191
- if (b && b.type === "tool_use" && typeof b.id === "string") {
192
- toolNames.set(b.id, b.name)
193
- }
194
- }
195
- const masked = redactBlocks(content)
196
- if (masked !== content && masked.some((b, i) => b !== content[i])) {
197
- return { ...obj, message: { ...message, content: masked } }
198
- }
199
- } else if (typeof content === "string") {
200
- const masked = redactSecrets(content)
201
- if (masked !== content) {
202
- return { ...obj, message: { ...message, content: masked } }
203
- }
204
- }
205
- return obj
206
- }
207
-
208
- // user: tool_result をスキル判定 or 秘匿情報マスク。素のテキスト発言も秘匿情報を伏せる。
209
- if (obj.type === "user") {
210
- if (Array.isArray(content)) {
211
- let changed = false
212
- const next = content.map((b) => {
213
- if (b && b.type === "tool_result") {
214
- const name = toolNames.get(b.tool_use_id)
215
- if (isSkillPromptTool(name)) {
216
- changed = true
217
- return { ...b, content: skillPlaceholderContent() }
218
- }
219
- if (isDeployCredentialTool(name)) {
220
- changed = true
221
- return { ...b, content: deployCredPlaceholderContent() }
222
- }
223
- // 非スキル tool_result: 中身 (string | block[]) の秘匿情報を伏せる。
224
- if (typeof b.content === "string") {
225
- const masked = redactSecrets(b.content)
226
- if (masked !== b.content) {
227
- changed = true
228
- return { ...b, content: masked }
229
- }
230
- return b
231
- }
232
- if (Array.isArray(b.content)) {
233
- const mc = redactBlocks(b.content)
234
- if (mc.some((x, i) => x !== b.content[i])) {
235
- changed = true
236
- return { ...b, content: mc }
237
- }
238
- return b
239
- }
240
- return b
241
- }
242
- if (b && b.type === "text" && typeof b.text === "string") {
243
- const masked = redactSecrets(b.text)
244
- if (masked !== b.text) {
245
- changed = true
246
- return { ...b, text: masked }
247
- }
248
- }
249
- return b
250
- })
251
- if (changed) return { ...obj, message: { ...message, content: next } }
252
- } else if (typeof content === "string") {
253
- const masked = redactSecrets(content)
254
- if (masked !== content) {
255
- return { ...obj, message: { ...message, content: masked } }
256
- }
257
- }
258
- return obj
259
- }
260
-
261
- return obj
257
+ if (obj.type !== "user" || !Array.isArray(message?.content)) return obj
258
+ let changed = false
259
+ const content = message.content.map((block) => {
260
+ if (block?.type !== "tool_result") return block
261
+ const placeholder = toolResultPlaceholder(toolNames.get(block.tool_use_id))
262
+ if (!placeholder) return block
263
+ if (Array.isArray(block.content) && block.content.length === 1
264
+ && block.content[0]?.type === "text" && block.content[0].text === placeholder) return block
265
+ changed = true
266
+ return { ...block, content: [{ type: "text", text: placeholder }] }
267
+ })
268
+ return changed ? { ...obj, message: { ...message, content } } : obj
262
269
  }
263
270
 
264
271
  /**
265
- * assistant メッセージ obj から tool_use の id→name を toolNames Map へ記録する
272
+ * assistant メッセージ obj から tool_use の id→表示用分類名を toolNames Map へ記録する。
273
+ * 値は実際の tool 名とは限らず、引数から分類した get_skill_prompt / deploy_creds も含む。
266
274
  * (マスクはしない)。履歴 hydrate で「tool_use 行が表示スライスの外へ truncate されても
267
275
  * tool_result のスキル判定が効く」よう、全行を事前走査して id→name を集めるのに使う。
268
276
  * @param {Record<string, unknown>} obj
@@ -272,12 +280,75 @@ export function collectToolUseNames(obj, toolNames) {
272
280
  if (obj?.type === "assistant" && Array.isArray(obj.message?.content)) {
273
281
  for (const b of obj.message.content) {
274
282
  if (b && b.type === "tool_use" && typeof b.id === "string") {
275
- toolNames.set(b.id, b.name)
283
+ toolNames.set(b.id, classifiedToolName(b))
276
284
  }
277
285
  }
278
286
  }
279
287
  }
280
288
 
289
+ // 保存用は従来の token regex と 4 文字以上の代入だけ。表示用の JSON/key/URI
290
+ // 拡張を呼ばないことで、Claude resume が読む原本の変更範囲を維持する。
291
+ const AT_REST_ASSIGN_PATTERN =
292
+ /\b([A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|AUTH[_-]?TOKEN)[A-Za-z0-9_]*)(\s*[=:]\s*)(['"]?)([^\s'"]{4,})\3/gi
293
+
294
+ function redactAtRestText(text) {
295
+ let out = text
296
+ for (const pattern of SECRET_PATTERNS) {
297
+ out = out.replace(pattern === DISPLAY_OPENAI_PATTERN ? AT_REST_OPENAI_PATTERN : pattern, SECRET_PLACEHOLDER)
298
+ }
299
+ return out.replace(AT_REST_ASSIGN_PATTERN,
300
+ (_match, key, sep, quote) => `${key}${sep}${quote}${SECRET_PLACEHOLDER}${quote}`)
301
+ }
302
+
303
+ function redactAtRestTextBlock(block) {
304
+ if (block?.type !== "text" || typeof block.text !== "string") return block
305
+ const text = redactAtRestText(block.text)
306
+ return text === block.text ? block : { ...block, text }
307
+ }
308
+
309
+ /** Existing at-rest message scope; tool names are real names, never display classifications. */
310
+ function maskAtRestMessage(obj, toolNames) {
311
+ if (!obj || typeof obj !== "object") return obj
312
+ if (obj.type === "system" && obj.subtype === "codex_delegate") {
313
+ let next = obj
314
+ for (const field of ["prompt", "description"]) {
315
+ if (typeof obj[field] !== "string") continue
316
+ const masked = redactAtRestText(obj[field])
317
+ if (masked !== obj[field]) next = { ...next, [field]: masked }
318
+ }
319
+ return next
320
+ }
321
+ const message = obj.message
322
+ if (!message || typeof message !== "object" || !["assistant", "user"].includes(obj.type)) return obj
323
+ const content = message.content
324
+ let next = content
325
+ if (typeof content === "string") next = redactAtRestText(content)
326
+ else if (Array.isArray(content)) {
327
+ next = content.map((block) => {
328
+ if (obj.type === "assistant" && block?.type === "tool_use" && typeof block.id === "string") {
329
+ toolNames.set(block.id, block.name)
330
+ }
331
+ if (obj.type !== "user" || block?.type !== "tool_result") return redactAtRestTextBlock(block)
332
+ const name = toolNames.get(block.tool_use_id)
333
+ const placeholder = isSkillPromptTool(name) ? SKILL_PROMPT_PLACEHOLDER
334
+ : isDeployCredentialTool(name) ? DEPLOY_CRED_PLACEHOLDER : null
335
+ // Keep the legacy rewrite behavior even for an already masked result.
336
+ if (placeholder) return { ...block, content: [{ type: "text", text: placeholder }] }
337
+ if (typeof block.content === "string") {
338
+ const masked = redactAtRestText(block.content)
339
+ return masked === block.content ? block : { ...block, content: masked }
340
+ }
341
+ if (Array.isArray(block.content)) {
342
+ const masked = block.content.map(redactAtRestTextBlock)
343
+ if (masked.some((item, i) => item !== block.content[i])) return { ...block, content: masked }
344
+ }
345
+ return block
346
+ })
347
+ if (next.every((block, i) => block === content[i])) return obj
348
+ }
349
+ return next === content ? obj : { ...obj, message: { ...message, content: next } }
350
+ }
351
+
281
352
  /**
282
353
  * ライブ stream / watch 用のステートフル masker を生成する。tool_use → tool_result が
283
354
  * 別イベントで届くため、id→name を内部 Map に蓄積しながら 1 イベントずつマスクする。
@@ -297,7 +368,7 @@ export function createEventMasker(initialToolNames) {
297
368
  }
298
369
 
299
370
  /**
300
- * jsonl ファイル全文 (改行区切り JSON) を 1 パスでマスクした文字列を返す。
371
+ * 保存用の従来 policy で jsonl ファイル全文をマスクする。表示は maskMessageObject を使う。
301
372
  * tool_use を先に通すと id→name が貯まるので、行順 (assistant→user) のまま走査すれば
302
373
  * tool_result のスキル判定が成立する。マスク対象が 1 件も無ければ null を返す
303
374
  * (呼び出し側が「書き換え不要」を判定して mtime を温存できる)。
@@ -321,7 +392,7 @@ export function maskJsonlText(text) {
321
392
  } catch {
322
393
  return line // パース不能行はそのまま (壊さない)
323
394
  }
324
- const masked = maskMessageObject(obj, toolNames)
395
+ const masked = maskAtRestMessage(obj, toolNames)
325
396
  if (masked !== obj) {
326
397
  changed = true
327
398
  return JSON.stringify(masked)