@cocorograph/hub-agent 0.7.7 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/claude-history.mjs +27 -2
- package/src/claude-stream-bridge.mjs +71 -3
- package/src/tool-result-mask.mjs +319 -0
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -17,6 +17,13 @@ import { open, readFile, readdir, stat } from "node:fs/promises"
|
|
|
17
17
|
import os from "node:os"
|
|
18
18
|
import path from "node:path"
|
|
19
19
|
|
|
20
|
+
import {
|
|
21
|
+
MASK_ENABLED,
|
|
22
|
+
collectToolUseNames,
|
|
23
|
+
maskMessageObject,
|
|
24
|
+
redactSecrets,
|
|
25
|
+
} from "./tool-result-mask.mjs"
|
|
26
|
+
|
|
20
27
|
export const MAX_HISTORY_LINES = 500
|
|
21
28
|
|
|
22
29
|
/** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
|
|
@@ -106,6 +113,20 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
106
113
|
const truncated = total_lines > maxLines
|
|
107
114
|
const slice = truncated ? lines.slice(-maxLines) : lines
|
|
108
115
|
|
|
116
|
+
// マスク (2026-06-23): tool_use 行が表示スライス外へ truncate されても tool_result の
|
|
117
|
+
// スキル判定が効くよう、全行を事前走査して tool_use_id→name を集めておく。
|
|
118
|
+
const toolNames = new Map()
|
|
119
|
+
if (MASK_ENABLED) {
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
if (!line.includes('"tool_use"')) continue
|
|
122
|
+
try {
|
|
123
|
+
collectToolUseNames(JSON.parse(line), toolNames)
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
109
130
|
const events = []
|
|
110
131
|
for (const line of slice) {
|
|
111
132
|
let obj
|
|
@@ -117,7 +138,10 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
117
138
|
if (!obj || typeof obj !== "object") continue
|
|
118
139
|
if (!DISPLAY_TYPES.has(obj.type)) continue
|
|
119
140
|
// SDK message と同じ shape にする (余分な meta は落とす)。整形は共通関数に集約。
|
|
120
|
-
|
|
141
|
+
let event = normalizeHistoryEvent(obj)
|
|
142
|
+
// 秘匿情報 / 非公開スキルプロンプト本文を hydrate 時にも伏せる (古いセッション再表示対策)。
|
|
143
|
+
if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
|
|
144
|
+
events.push(event)
|
|
121
145
|
}
|
|
122
146
|
return { events, total_lines, truncated }
|
|
123
147
|
}
|
|
@@ -226,7 +250,8 @@ async function extractPreview(filePath) {
|
|
|
226
250
|
if (textBlock) str = textBlock.text
|
|
227
251
|
}
|
|
228
252
|
const preview = normalizePreviewText(str)
|
|
229
|
-
|
|
253
|
+
// 一覧の preview にユーザーが貼った秘匿情報が出ないよう伏せる。
|
|
254
|
+
if (preview) return MASK_ENABLED ? redactSecrets(preview) : preview
|
|
230
255
|
}
|
|
231
256
|
}
|
|
232
257
|
return ""
|
|
@@ -26,6 +26,11 @@ import path from "node:path"
|
|
|
26
26
|
|
|
27
27
|
import { jsonlPath } from "./claude-history.mjs"
|
|
28
28
|
import { watchSessionFile } from "./claude-history-watch.mjs"
|
|
29
|
+
import {
|
|
30
|
+
MASK_ENABLED,
|
|
31
|
+
createEventMasker,
|
|
32
|
+
maskJsonlFileAtRest,
|
|
33
|
+
} from "./tool-result-mask.mjs"
|
|
29
34
|
|
|
30
35
|
/** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
|
|
31
36
|
* かつ走行中でなければ撤去する。端末スリープ/長期離席後の再接続も吸収できるよう 7 日に
|
|
@@ -305,10 +310,62 @@ class ClaudeStreamSession {
|
|
|
305
310
|
this._watcher = null
|
|
306
311
|
/** watcher が監視中の session_id (変化時に張り替え) */
|
|
307
312
|
this._watchedSessionId = null
|
|
313
|
+
|
|
314
|
+
/** マスク (2026-06-23): ライブ stream / watch 用のステートフル masker。tool_use_id→name を
|
|
315
|
+
* 蓄積し、tool_result の秘匿情報 / 非公開スキルプロンプト本文を表示・配信前に伏せる。
|
|
316
|
+
* モデル消費後のコピーに作用するためスキル実行は壊れない。 */
|
|
317
|
+
this._eventMasker = createEventMasker()
|
|
318
|
+
/** オンディスク jsonl をマスク済みか (close 時の二重書き換えを防ぐ冪等ガード)。 */
|
|
319
|
+
this._jsonlMasked = false
|
|
320
|
+
|
|
308
321
|
// resume セッションがあれば即 watch 開始 (閲覧中の外部進行をライブ反映)
|
|
309
322
|
if (this.sessionId) this._ensureWatch()
|
|
310
323
|
}
|
|
311
324
|
|
|
325
|
+
/** マスク済みイベントを onEvent へ流す共通経路。秘匿情報 / 非公開スキルプロンプト本文を
|
|
326
|
+
* 表示・WS 配信前に伏せる。マスク失敗時は素のイベントを流す (表示が止まるより素通し優先)。 */
|
|
327
|
+
_emit(event) {
|
|
328
|
+
if (!this.onEvent) return
|
|
329
|
+
let out = event
|
|
330
|
+
if (MASK_ENABLED) {
|
|
331
|
+
try {
|
|
332
|
+
out = this._eventMasker(event)
|
|
333
|
+
} catch (err) {
|
|
334
|
+
this.logger?.warn(
|
|
335
|
+
{ err: err?.message, stream_id: this.stream_id },
|
|
336
|
+
"event mask failed",
|
|
337
|
+
)
|
|
338
|
+
out = event
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
this.onEvent(out)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** セッション終了時にオンディスク jsonl をマスク書き換えする (冪等)。Claude CLI が当該
|
|
345
|
+
* ファイルを掴んでいない「終了後」にのみ呼ぶこと (ライブ追記中の書き換えは会話本体を壊す)。
|
|
346
|
+
* display は _emit で常時マスク済みのため、これは at-rest ファイルの掃除専用。 */
|
|
347
|
+
_maskJsonlAtRest() {
|
|
348
|
+
if (this._jsonlMasked || !MASK_ENABLED) return
|
|
349
|
+
if (!this.sessionId || !this.cwd) return
|
|
350
|
+
this._jsonlMasked = true
|
|
351
|
+
const filePath = jsonlPath({ cwd: this.cwd, session_id: this.sessionId })
|
|
352
|
+
maskJsonlFileAtRest(filePath, { logger: this.logger })
|
|
353
|
+
.then((changed) => {
|
|
354
|
+
if (changed) {
|
|
355
|
+
this.logger?.info(
|
|
356
|
+
{ stream_id: this.stream_id, session_id: this.sessionId },
|
|
357
|
+
"jsonl masked at rest",
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
.catch((err) =>
|
|
362
|
+
this.logger?.warn(
|
|
363
|
+
{ err: err?.message, stream_id: this.stream_id },
|
|
364
|
+
"mask jsonl at rest threw",
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
312
369
|
/** 現在の sessionId の jsonl を watch する (既に同じものを watch 中なら何もしない)。 */
|
|
313
370
|
_ensureWatch() {
|
|
314
371
|
if (this._closed || !this.sessionId || !this.cwd) return
|
|
@@ -333,7 +390,7 @@ class ClaudeStreamSession {
|
|
|
333
390
|
// (二重表示防止)。ターン完了時に skipToEnd で読み飛ばす。
|
|
334
391
|
if (this._busy) return
|
|
335
392
|
try {
|
|
336
|
-
this.
|
|
393
|
+
this._emit(event)
|
|
337
394
|
} catch (err) {
|
|
338
395
|
this.logger?.warn(
|
|
339
396
|
{ err: err.message, stream_id: this.stream_id },
|
|
@@ -690,7 +747,7 @@ class ClaudeStreamSession {
|
|
|
690
747
|
if (typeof msg.session_id === "string") this.sessionId = msg.session_id
|
|
691
748
|
}
|
|
692
749
|
try {
|
|
693
|
-
this.
|
|
750
|
+
this._emit(msg)
|
|
694
751
|
} catch (err) {
|
|
695
752
|
this.logger?.warn(
|
|
696
753
|
{ err: err.message, stream_id: this.stream_id },
|
|
@@ -764,6 +821,10 @@ class ClaudeStreamSession {
|
|
|
764
821
|
// 停止 (abort) 後も温存したキューはそのまま流す。
|
|
765
822
|
this._drainPending()
|
|
766
823
|
}
|
|
824
|
+
// マスク (2026-06-23): 走行中に外部 close されたターンの後始末。ターン完走後 (= CLI が
|
|
825
|
+
// jsonl を掴んでいない) のこの地点で、closed 済みならオンディスク jsonl をマスクする。
|
|
826
|
+
// reap 経路は上の close() 内で既にマスク済み (冪等ガードで二重実行されない)。
|
|
827
|
+
if (this._closed) this._maskJsonlAtRest()
|
|
767
828
|
}
|
|
768
829
|
}
|
|
769
830
|
|
|
@@ -978,7 +1039,7 @@ class ClaudeStreamSession {
|
|
|
978
1039
|
)
|
|
979
1040
|
}
|
|
980
1041
|
try {
|
|
981
|
-
this.
|
|
1042
|
+
this._emit(msg)
|
|
982
1043
|
} catch (err) {
|
|
983
1044
|
this.logger?.warn(
|
|
984
1045
|
{ err: err.message, stream_id: this.stream_id },
|
|
@@ -1028,6 +1089,9 @@ class ClaudeStreamSession {
|
|
|
1028
1089
|
this._inputQueue.push(toSDKUserMessage(next.text))
|
|
1029
1090
|
this._emitQueueState([next.text])
|
|
1030
1091
|
}
|
|
1092
|
+
// マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
|
|
1093
|
+
// closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
|
|
1094
|
+
if (this._closed) this._maskJsonlAtRest()
|
|
1031
1095
|
}
|
|
1032
1096
|
}
|
|
1033
1097
|
|
|
@@ -1105,6 +1169,10 @@ class ClaudeStreamSession {
|
|
|
1105
1169
|
}
|
|
1106
1170
|
}
|
|
1107
1171
|
this._permissionResolvers.clear()
|
|
1172
|
+
// マスク (2026-06-23): アイドル close (走行ターン無し = CLI が jsonl を掴んでいない) は
|
|
1173
|
+
// ここで即マスクする。走行中 close (abortTurn 経由) は CLI 終了を待つため run-loop の
|
|
1174
|
+
// finally 側でマスクする (上の _maskJsonlAtRest 冪等ガードで二重実行は防がれる)。
|
|
1175
|
+
if (!this._busy) this._maskJsonlAtRest()
|
|
1108
1176
|
}
|
|
1109
1177
|
}
|
|
1110
1178
|
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ツールリザルト・メッセージ本文のマスク (秘匿情報 + 非公開スキルプロンプト本文)。
|
|
3
|
+
*
|
|
4
|
+
* 背景 (2026-06-23): Cockpit TUI チャットモードは hub-agent が Claude Agent SDK の
|
|
5
|
+
* stream を中継してブラウザに表示する。ターミナル版 Claude Code が持つ「シークレット
|
|
6
|
+
* 自動伏せ字」は **ハーネス側の機能** で、この独自経路には継承されていなかったため、
|
|
7
|
+
* tool_result に含まれる API キー / トークン / パスワード等が素のまま画面・WS 配信・
|
|
8
|
+
* jsonl へ流れていた。さらに Hub ナレッジに格納した **非公開スキルプロンプト本文**
|
|
9
|
+
* (`get_skill_prompt` MCP の戻り値) も毎回リザルトに露出していた。
|
|
10
|
+
*
|
|
11
|
+
* 本モジュールは「表示・配信・記録されるコピー」だけをマスクする。モデルが実際に受け取る
|
|
12
|
+
* 入力は SDK stream の上流 (Claude CLI) で確定しており、hub-agent に届くのは **モデルが
|
|
13
|
+
* 消費した後** のイベントなので、ここでマスクしてもモデルの動作・スキル実行は壊れない
|
|
14
|
+
* (詳細は claude-stream-bridge.mjs の emit 経路コメント参照)。
|
|
15
|
+
*
|
|
16
|
+
* 2 種類のマスク:
|
|
17
|
+
* - 秘匿情報: 正規表現で既知トークン形 / sensitive な KEY=VALUE 代入を伏せ字化する。
|
|
18
|
+
* モデルは元々不要なので、オンディスク jsonl をマスクしても resume は無傷。
|
|
19
|
+
* - スキルプロンプト本文: `get_skill_prompt` (MCP 名前空間込み) の tool_result 本文を
|
|
20
|
+
* まるごとプレースホルダへ置換する。tool_use → tool_result の id 対応が必要なため、
|
|
21
|
+
* stream にはステートフルな masker (createEventMasker)、jsonl 一括には 1 パスの
|
|
22
|
+
* maskJsonlText を用意する。
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ 限定事項 (v1): includePartialMessages の **部分デルタ (stream_event)** はマスク
|
|
25
|
+
* しない。秘匿情報の主出所である tool_result / .env ダンプは完成済みメッセージとして
|
|
26
|
+
* 届くためここで捕捉でき、最終メッセージ・jsonl・hydrate も全てマスクされる。アシスタント
|
|
27
|
+
* が逐次生成する途中テキストにシークレットが現れる確率は低いと判断し v1 では対象外。
|
|
28
|
+
*/
|
|
29
|
+
import { readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises"
|
|
30
|
+
|
|
31
|
+
/** env HUB_AGENT_TOOL_RESULT_MASK="0" で無効化 (既定 ON)。誤マスク時の即時ロールバック用。 */
|
|
32
|
+
export const MASK_ENABLED = process.env.HUB_AGENT_TOOL_RESULT_MASK !== "0"
|
|
33
|
+
|
|
34
|
+
/** 秘匿情報の置換マーカー (grep しやすい固定文字列)。 */
|
|
35
|
+
export const SECRET_PLACEHOLDER = "[REDACTED]"
|
|
36
|
+
|
|
37
|
+
/** スキルプロンプト本文の置換マーカー。再取得可能であることを明示する。 */
|
|
38
|
+
export const SKILL_PROMPT_PLACEHOLDER =
|
|
39
|
+
"[get_skill_prompt の本文はマスクされています(必要なら再取得できます)]"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 既知の秘匿情報パターン (誤検知を抑えるため、形が明確なトークンに限定する)。
|
|
43
|
+
* 各要素は global フラグ付き RegExp。replace で SECRET_PLACEHOLDER に置換する。
|
|
44
|
+
*/
|
|
45
|
+
const SECRET_PATTERNS = [
|
|
46
|
+
// PEM 秘密鍵ブロック (複数行)。RSA/EC/OPENSSH/DSA/PGP の修飾子に対応。
|
|
47
|
+
/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
|
|
48
|
+
// Anthropic / OpenAI 系 API キー (sk-ant-... を先に置換するため先頭に)。
|
|
49
|
+
/sk-ant-[A-Za-z0-9_-]{20,}/g,
|
|
50
|
+
/sk-[A-Za-z0-9]{20,}/g,
|
|
51
|
+
// AWS アクセスキー ID。
|
|
52
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
53
|
+
// GitHub トークン (ghp_/gho_/ghu_/ghs_/ghr_ と fine-grained PAT)。
|
|
54
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
55
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
56
|
+
// Slack トークン。
|
|
57
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
58
|
+
// Google API キー (固定長 35 のため末尾境界は課さない)。
|
|
59
|
+
/\bAIza[0-9A-Za-z_-]{35}/g,
|
|
60
|
+
// JWT (header.payload.signature)。
|
|
61
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
62
|
+
// Authorization ヘッダ (Bearer / Basic)。値だけでなくスキームごと伏せる。
|
|
63
|
+
/\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
|
|
64
|
+
/\bBasic\s+[A-Za-z0-9+/]{12,}=*/g,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* sensitive な名前の KEY=VALUE / KEY: VALUE 代入を伏せ字化する。キー名は残し値だけ消す
|
|
69
|
+
* (例: `DB_PASSWORD=hunter2` → `DB_PASSWORD=[REDACTED]`)。.env ダンプ / 環境変数出力対策。
|
|
70
|
+
* 値は 4 文字以上のとき対象 (短い値の誤検知回避)。引用符は維持する。
|
|
71
|
+
*/
|
|
72
|
+
const ASSIGN_PATTERN =
|
|
73
|
+
/\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
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 文字列中の秘匿情報を伏せ字化する。マスク対象が無ければ同一文字列をそのまま返す。
|
|
77
|
+
* @param {string} text
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function redactSecrets(text) {
|
|
81
|
+
if (typeof text !== "string" || text.length === 0) return text
|
|
82
|
+
let out = text
|
|
83
|
+
for (const re of SECRET_PATTERNS) {
|
|
84
|
+
out = out.replace(re, SECRET_PLACEHOLDER)
|
|
85
|
+
}
|
|
86
|
+
out = out.replace(
|
|
87
|
+
ASSIGN_PATTERN,
|
|
88
|
+
(_m, key, sep, quote, _val) => `${key}${sep}${quote}${SECRET_PLACEHOLDER}${quote}`,
|
|
89
|
+
)
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* tool 名が非公開スキルプロンプト取得ツールか判定する。MCP 名前空間が付くため
|
|
95
|
+
* (`mcp__hub__get_skill_prompt` / `mcp__hub_staff__get_skill_prompt`)、末尾一致でも拾う。
|
|
96
|
+
* @param {unknown} name
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
export function isSkillPromptTool(name) {
|
|
100
|
+
return (
|
|
101
|
+
typeof name === "string" &&
|
|
102
|
+
(name === "get_skill_prompt" || name.endsWith("__get_skill_prompt"))
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** tool_result の content (string | block[]) をプレースホルダ 1 本へ潰す。 */
|
|
107
|
+
function skillPlaceholderContent() {
|
|
108
|
+
return [{ type: "text", text: SKILL_PROMPT_PLACEHOLDER }]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** content blocks 配列の text を redactSecrets で処理した新配列を返す (非破壊)。 */
|
|
112
|
+
function redactBlocks(blocks) {
|
|
113
|
+
if (!Array.isArray(blocks)) return blocks
|
|
114
|
+
return blocks.map((b) => {
|
|
115
|
+
if (b && b.type === "text" && typeof b.text === "string") {
|
|
116
|
+
const masked = redactSecrets(b.text)
|
|
117
|
+
return masked === b.text ? b : { ...b, text: masked }
|
|
118
|
+
}
|
|
119
|
+
return b
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* SDK message 形 (jsonl 1 行 / live event) のオブジェクトをマスクした **コピー** を返す。
|
|
125
|
+
* 変更が無ければ元オブジェクトをそのまま返す (参照同一で「変化なし」を呼び出し側が判定可)。
|
|
126
|
+
*
|
|
127
|
+
* @param {Record<string, unknown>} obj type: 'assistant' | 'user' | ... を持つメッセージ
|
|
128
|
+
* @param {Map<string,string>} toolNames tool_use_id → tool 名。assistant を通すたびに更新する。
|
|
129
|
+
* @returns {Record<string, unknown>}
|
|
130
|
+
*/
|
|
131
|
+
export function maskMessageObject(obj, toolNames) {
|
|
132
|
+
if (!obj || typeof obj !== "object") return obj
|
|
133
|
+
const message = obj.message
|
|
134
|
+
if (!message || typeof message !== "object") return obj
|
|
135
|
+
const content = message.content
|
|
136
|
+
|
|
137
|
+
// assistant: tool_use の id→name を記録しつつ text ブロックの秘匿情報を伏せる。
|
|
138
|
+
if (obj.type === "assistant") {
|
|
139
|
+
if (Array.isArray(content)) {
|
|
140
|
+
for (const b of content) {
|
|
141
|
+
if (b && b.type === "tool_use" && typeof b.id === "string") {
|
|
142
|
+
toolNames.set(b.id, b.name)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const masked = redactBlocks(content)
|
|
146
|
+
if (masked !== content && masked.some((b, i) => b !== content[i])) {
|
|
147
|
+
return { ...obj, message: { ...message, content: masked } }
|
|
148
|
+
}
|
|
149
|
+
} else if (typeof content === "string") {
|
|
150
|
+
const masked = redactSecrets(content)
|
|
151
|
+
if (masked !== content) {
|
|
152
|
+
return { ...obj, message: { ...message, content: masked } }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return obj
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// user: tool_result をスキル判定 or 秘匿情報マスク。素のテキスト発言も秘匿情報を伏せる。
|
|
159
|
+
if (obj.type === "user") {
|
|
160
|
+
if (Array.isArray(content)) {
|
|
161
|
+
let changed = false
|
|
162
|
+
const next = content.map((b) => {
|
|
163
|
+
if (b && b.type === "tool_result") {
|
|
164
|
+
const name = toolNames.get(b.tool_use_id)
|
|
165
|
+
if (isSkillPromptTool(name)) {
|
|
166
|
+
changed = true
|
|
167
|
+
return { ...b, content: skillPlaceholderContent() }
|
|
168
|
+
}
|
|
169
|
+
// 非スキル tool_result: 中身 (string | block[]) の秘匿情報を伏せる。
|
|
170
|
+
if (typeof b.content === "string") {
|
|
171
|
+
const masked = redactSecrets(b.content)
|
|
172
|
+
if (masked !== b.content) {
|
|
173
|
+
changed = true
|
|
174
|
+
return { ...b, content: masked }
|
|
175
|
+
}
|
|
176
|
+
return b
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(b.content)) {
|
|
179
|
+
const mc = redactBlocks(b.content)
|
|
180
|
+
if (mc.some((x, i) => x !== b.content[i])) {
|
|
181
|
+
changed = true
|
|
182
|
+
return { ...b, content: mc }
|
|
183
|
+
}
|
|
184
|
+
return b
|
|
185
|
+
}
|
|
186
|
+
return b
|
|
187
|
+
}
|
|
188
|
+
if (b && b.type === "text" && typeof b.text === "string") {
|
|
189
|
+
const masked = redactSecrets(b.text)
|
|
190
|
+
if (masked !== b.text) {
|
|
191
|
+
changed = true
|
|
192
|
+
return { ...b, text: masked }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return b
|
|
196
|
+
})
|
|
197
|
+
if (changed) return { ...obj, message: { ...message, content: next } }
|
|
198
|
+
} else if (typeof content === "string") {
|
|
199
|
+
const masked = redactSecrets(content)
|
|
200
|
+
if (masked !== content) {
|
|
201
|
+
return { ...obj, message: { ...message, content: masked } }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return obj
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return obj
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* assistant メッセージ obj から tool_use の id→name を toolNames Map へ記録する
|
|
212
|
+
* (マスクはしない)。履歴 hydrate で「tool_use 行が表示スライスの外へ truncate されても
|
|
213
|
+
* tool_result のスキル判定が効く」よう、全行を事前走査して id→name を集めるのに使う。
|
|
214
|
+
* @param {Record<string, unknown>} obj
|
|
215
|
+
* @param {Map<string,string>} toolNames
|
|
216
|
+
*/
|
|
217
|
+
export function collectToolUseNames(obj, toolNames) {
|
|
218
|
+
if (obj?.type === "assistant" && Array.isArray(obj.message?.content)) {
|
|
219
|
+
for (const b of obj.message.content) {
|
|
220
|
+
if (b && b.type === "tool_use" && typeof b.id === "string") {
|
|
221
|
+
toolNames.set(b.id, b.name)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* ライブ stream / watch 用のステートフル masker を生成する。tool_use → tool_result が
|
|
229
|
+
* 別イベントで届くため、id→name を内部 Map に蓄積しながら 1 イベントずつマスクする。
|
|
230
|
+
* 1 セッション 1 インスタンスで使う (stream と watch は同一セッション内で直列化される)。
|
|
231
|
+
*
|
|
232
|
+
* @returns {(event: object) => object} マスク済みイベント (変化なしなら同一参照)
|
|
233
|
+
*/
|
|
234
|
+
export function createEventMasker() {
|
|
235
|
+
const toolNames = new Map()
|
|
236
|
+
return function maskEvent(event) {
|
|
237
|
+
if (!MASK_ENABLED) return event
|
|
238
|
+
return maskMessageObject(event, toolNames)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* jsonl ファイル全文 (改行区切り JSON) を 1 パスでマスクした文字列を返す。
|
|
244
|
+
* tool_use を先に通すと id→name が貯まるので、行順 (assistant→user) のまま走査すれば
|
|
245
|
+
* tool_result のスキル判定が成立する。マスク対象が 1 件も無ければ null を返す
|
|
246
|
+
* (呼び出し側が「書き換え不要」を判定して mtime を温存できる)。
|
|
247
|
+
*
|
|
248
|
+
* @param {string} text jsonl 全文
|
|
249
|
+
* @returns {string|null} マスク後の全文 (末尾改行は入力に合わせる)。変化なしは null。
|
|
250
|
+
*/
|
|
251
|
+
export function maskJsonlText(text) {
|
|
252
|
+
if (!MASK_ENABLED || typeof text !== "string" || text.length === 0) return null
|
|
253
|
+
const toolNames = new Map()
|
|
254
|
+
const hadTrailingNewline = text.endsWith("\n")
|
|
255
|
+
const rawLines = text.split("\n")
|
|
256
|
+
// 末尾の空要素 (trailing newline 由来) は出力時に再付与するので走査から外す。
|
|
257
|
+
if (hadTrailingNewline) rawLines.pop()
|
|
258
|
+
let changed = false
|
|
259
|
+
const outLines = rawLines.map((line) => {
|
|
260
|
+
if (!line) return line
|
|
261
|
+
let obj
|
|
262
|
+
try {
|
|
263
|
+
obj = JSON.parse(line)
|
|
264
|
+
} catch {
|
|
265
|
+
return line // パース不能行はそのまま (壊さない)
|
|
266
|
+
}
|
|
267
|
+
const masked = maskMessageObject(obj, toolNames)
|
|
268
|
+
if (masked !== obj) {
|
|
269
|
+
changed = true
|
|
270
|
+
return JSON.stringify(masked)
|
|
271
|
+
}
|
|
272
|
+
return line
|
|
273
|
+
})
|
|
274
|
+
if (!changed) return null
|
|
275
|
+
return outLines.join("\n") + (hadTrailingNewline ? "\n" : "")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* オンディスク jsonl をマスク書き換えする (セッション終了時に呼ぶ)。
|
|
280
|
+
*
|
|
281
|
+
* ⚠️ 呼び出しタイミングは「Claude CLI がそのファイルを掴んでいない (= セッション終了後)」
|
|
282
|
+
* に限ること。ライブ追記中に書き換えると会話本体を壊しセッションが死ぬ (jsonl は CLI が
|
|
283
|
+
* 追記オーナー)。終了後なら誰も掴んでいないので tmp 書き出し → rename で安全に置換できる。
|
|
284
|
+
*
|
|
285
|
+
* mtime を温存する: listSessions が mtime 降順でセッションを並べるため、マスク書き換えで
|
|
286
|
+
* 古いセッションが「最新」へ浮上する誤表示を防ぐ。マスク対象が無ければ書き込まない (no-op)。
|
|
287
|
+
*
|
|
288
|
+
* @param {string} filePath
|
|
289
|
+
* @param {{logger?: import('pino').Logger}} [opts]
|
|
290
|
+
* @returns {Promise<boolean>} 実際に書き換えたら true
|
|
291
|
+
*/
|
|
292
|
+
export async function maskJsonlFileAtRest(filePath, { logger } = {}) {
|
|
293
|
+
if (!MASK_ENABLED || !filePath) return false
|
|
294
|
+
let text
|
|
295
|
+
let st
|
|
296
|
+
try {
|
|
297
|
+
st = await stat(filePath)
|
|
298
|
+
text = await readFile(filePath, "utf-8")
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (err?.code !== "ENOENT") {
|
|
301
|
+
logger?.warn({ err: err?.message, filePath }, "mask jsonl read failed")
|
|
302
|
+
}
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
const masked = maskJsonlText(text)
|
|
306
|
+
if (masked == null) return false // マスク対象なし → 触らない (mtime 温存)
|
|
307
|
+
const tmp = `${filePath}.mask-tmp-${process.pid}`
|
|
308
|
+
try {
|
|
309
|
+
await writeFile(tmp, masked, "utf-8")
|
|
310
|
+
await rename(tmp, filePath)
|
|
311
|
+
// rename 後の新ファイルは現在時刻 mtime になるので、元の atime/mtime へ戻す。
|
|
312
|
+
await utimes(filePath, st.atime, st.mtime).catch(() => {})
|
|
313
|
+
return true
|
|
314
|
+
} catch (err) {
|
|
315
|
+
logger?.warn({ err: err?.message, filePath }, "mask jsonl write failed")
|
|
316
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
}
|