@cocorograph/hub-agent 0.8.63 → 0.8.65
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/README.md +17 -0
- package/package.json +1 -1
- package/src/claude-display-mask.mjs +89 -0
- package/src/claude-stream-bridge.mjs +62 -13
- package/src/codex-result-mask.mjs +187 -0
- package/src/codex-stream-bridge.mjs +19 -0
- package/src/main.mjs +5 -2
- package/src/permission-display-mask.mjs +142 -0
- package/src/tool-result-mask.mjs +201 -130
package/README.md
CHANGED
|
@@ -77,6 +77,23 @@ hub-agent plugins uninstall <name> # ~/.hub/plugins/<prio>-<name>/ を削除
|
|
|
77
77
|
|
|
78
78
|
## 構成
|
|
79
79
|
|
|
80
|
+
### チャット表示のマスク
|
|
81
|
+
|
|
82
|
+
Claude / Codex のチャットは、既定で表示・WebSocket 配信用コピーをマスクします。
|
|
83
|
+
`get_skill_prompt`、`skill/`・`エージェント/` の内部本文取得、デプロイ接続情報取得の
|
|
84
|
+
結果を非表示にし、通常の本文・引数・エラー・差分に含まれる既知の秘密値も伏せます。
|
|
85
|
+
一般のナレッジ取得や通常のツール結果は表示します。
|
|
86
|
+
|
|
87
|
+
- 分割された秘密値の漏出を防ぐため、文字単位の途中出力ではなく完成済み本文を表示します。
|
|
88
|
+
- コード実行ラッパーに対象ツール名が明記されている場合、その実行結果全体を非表示にします。
|
|
89
|
+
動的に組み立てたツール名や、任意の文章に埋め込まれた未知形式の秘密を完全検出する機能ではありません。
|
|
90
|
+
- 許可確認には伏せた入力を渡し、変更されなかった伏せ字を元の入力へ戻して実行します。
|
|
91
|
+
一意に復元できない編集は拒否します。マスク失敗時に生の本文へ戻すことはしません。
|
|
92
|
+
- Codex のモデル入力・再開用 rollout 原本は変更しません。Claude の既存の終了後 JSONL
|
|
93
|
+
マスクは継続します。ターミナル表示や過去に配信済みのデータを遡って消すものではありません。
|
|
94
|
+
- 緊急時の切り戻し設定 `HUB_AGENT_TOOL_RESULT_MASK=0` は、秘密値を含め無加工で配信します。
|
|
95
|
+
通常運用では無効化しないでください。
|
|
96
|
+
|
|
80
97
|
```
|
|
81
98
|
hub-agent/
|
|
82
99
|
├── bin/
|
package/package.json
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createEventMasker, MASK_ENABLED, redactSecretsValue } from "./tool-result-mask.mjs"
|
|
2
|
+
|
|
3
|
+
/** Also cover errors, ready metadata and permission copies outside SDK _emit.
|
|
4
|
+
* Recheck the complete envelope deliberately: ready/history can combine already
|
|
5
|
+
* masked events with newly added metadata. A type-based bypass would trust those
|
|
6
|
+
* unrelated fields too. Do not trade this boundary for a size-based raw fallback. */
|
|
7
|
+
export function installClaudeDisplayMask(client) {
|
|
8
|
+
const send = client.send.bind(client)
|
|
9
|
+
client.send = (payload, ...args) => {
|
|
10
|
+
if (!MASK_ENABLED || !payload?.type?.startsWith("claude.")) return send(payload, ...args)
|
|
11
|
+
let masked
|
|
12
|
+
try {
|
|
13
|
+
masked = redactSecretsValue(payload)
|
|
14
|
+
} catch {
|
|
15
|
+
masked = {
|
|
16
|
+
type: "claude.error",
|
|
17
|
+
stream_id: payload.stream_id,
|
|
18
|
+
session_id: payload.session_id,
|
|
19
|
+
error: "[本文を安全にマスクできないため表示を省略しました]",
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return send(masked, ...args)
|
|
23
|
+
}
|
|
24
|
+
return client
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A partial string cannot be redacted independently: a key or a quoted value may
|
|
29
|
+
* straddle notifications. Keep lifecycle notifications, but publish text,
|
|
30
|
+
* thinking and tool arguments only with the complete assistant message. The
|
|
31
|
+
* SDK/model input and the original event are never modified here.
|
|
32
|
+
*/
|
|
33
|
+
export function createClaudeDisplayMasker(initialToolNames) {
|
|
34
|
+
const mask = createEventMasker(initialToolNames)
|
|
35
|
+
return (event) => {
|
|
36
|
+
if (!MASK_ENABLED) return event
|
|
37
|
+
if (event?.type === "stream_event") {
|
|
38
|
+
const partial = event.event
|
|
39
|
+
if (partial?.type === "content_block_delta") {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
if (partial?.type === "content_block_start") {
|
|
43
|
+
const block = partial.content_block
|
|
44
|
+
return mask({
|
|
45
|
+
...event,
|
|
46
|
+
event: {
|
|
47
|
+
type: partial.type,
|
|
48
|
+
index: partial.index,
|
|
49
|
+
content_block: {
|
|
50
|
+
type: block?.type,
|
|
51
|
+
...(block?.id ? { id: block.id } : {}),
|
|
52
|
+
...(block?.name ? { name: block.name } : {}),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return mask(event)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Failure must not disclose either the source payload or an exception message. */
|
|
63
|
+
export function claudeMaskFailure(event) {
|
|
64
|
+
const notice = "[本文を安全にマスクできないため表示を省略しました]"
|
|
65
|
+
if (event?.type === "result") {
|
|
66
|
+
return {
|
|
67
|
+
type: "result",
|
|
68
|
+
subtype: event.subtype,
|
|
69
|
+
is_error: event.is_error,
|
|
70
|
+
session_id: event.session_id,
|
|
71
|
+
result: notice,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (event?.type === "system" && event.subtype === "session_state_changed") {
|
|
75
|
+
return { type: "system", subtype: event.subtype, state: event.state }
|
|
76
|
+
}
|
|
77
|
+
if (event?.type === "assistant" || event?.type === "user") {
|
|
78
|
+
// Keep a renderable completed message and numeric accounting only. Do not
|
|
79
|
+
// copy arbitrary usage/provider metadata from the failed source envelope.
|
|
80
|
+
const usage = Object.fromEntries([
|
|
81
|
+
"input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens",
|
|
82
|
+
].filter(key => typeof event.message?.usage?.[key] === "number" && Number.isFinite(event.message.usage[key]))
|
|
83
|
+
.map(key => [key, event.message.usage[key]]))
|
|
84
|
+
return { type: event.type, message: { role: event.type,
|
|
85
|
+
content: [{ type: "text", text: notice }], ...(Object.keys(usage).length ? { usage } : {}),
|
|
86
|
+
} }
|
|
87
|
+
}
|
|
88
|
+
return { type: "system", subtype: "mask_error", message: notice }
|
|
89
|
+
}
|
|
@@ -27,9 +27,10 @@ import { jsonlPath } from "./claude-history.mjs"
|
|
|
27
27
|
import { watchSessionFile } from "./claude-history-watch.mjs"
|
|
28
28
|
import {
|
|
29
29
|
MASK_ENABLED,
|
|
30
|
-
createEventMasker,
|
|
31
30
|
maskJsonlFileAtRest,
|
|
32
31
|
} from "./tool-result-mask.mjs"
|
|
32
|
+
import { createClaudeDisplayMasker, claudeMaskFailure } from "./claude-display-mask.mjs"
|
|
33
|
+
import { copyPermissionInput, preparePermissionInput, restorePermissionInput, PERMISSION_MASK_DENIAL } from "./permission-display-mask.mjs"
|
|
33
34
|
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
34
35
|
import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
35
36
|
import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
|
|
@@ -563,6 +564,9 @@ export class ClaudeStreamSession {
|
|
|
563
564
|
* メッセージが後着して実行される「ゴースト実行」を防ぐ)。到着で消費 (delete)。
|
|
564
565
|
* 無限増加を避けるため上限で全クリアする (キャンセルは低頻度)。 */
|
|
565
566
|
this._canceledInputIds = new Set()
|
|
567
|
+
/** 取消成功した input_id の直近リング。accepted は配達確認と二重実行防止の根拠なので外さず、
|
|
568
|
+
* browser が「accepted + キュー不在」のバブル昇格から取消済み id を除外する別軸にする。 */
|
|
569
|
+
this._cancelledInputIds = []
|
|
566
570
|
/** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
|
|
567
571
|
* 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
|
|
568
572
|
this._lastEmittedQueueSig = "0:"
|
|
@@ -690,7 +694,7 @@ export class ClaudeStreamSession {
|
|
|
690
694
|
/** マスク (2026-06-23): ライブ stream / watch 用のステートフル masker。tool_use_id→name を
|
|
691
695
|
* 蓄積し、tool_result の秘匿情報 / 非公開スキルプロンプト本文を表示・配信前に伏せる。
|
|
692
696
|
* モデル消費後のコピーに作用するためスキル実行は壊れない。 */
|
|
693
|
-
this._eventMasker =
|
|
697
|
+
this._eventMasker = createClaudeDisplayMasker()
|
|
694
698
|
/** オンディスク jsonl をマスク済みか (close 時の二重書き換えを防ぐ冪等ガード)。 */
|
|
695
699
|
this._jsonlMasked = false
|
|
696
700
|
|
|
@@ -699,7 +703,7 @@ export class ClaudeStreamSession {
|
|
|
699
703
|
}
|
|
700
704
|
|
|
701
705
|
/** マスク済みイベントを onEvent へ流す共通経路。秘匿情報 / 非公開スキルプロンプト本文を
|
|
702
|
-
* 表示・WS
|
|
706
|
+
* 表示・WS 配信前に伏せる。マスク失敗時も元の本文へ戻さない。 */
|
|
703
707
|
/** 連携カード種別 (Phase 2): 注入本文と source を FIFO に控える。sendToCwd/sendToStream が
|
|
704
708
|
* session.sendMessage() を呼ぶ直前に記録し、**drain 昇格時に _emitQueueState が started 本文
|
|
705
709
|
* 一致で消費**して startedSources として frontend へ載せる。
|
|
@@ -843,15 +847,15 @@ export class ClaudeStreamSession {
|
|
|
843
847
|
if (MASK_ENABLED) {
|
|
844
848
|
try {
|
|
845
849
|
out = this._eventMasker(event)
|
|
846
|
-
} catch
|
|
850
|
+
} catch {
|
|
847
851
|
this.logger?.warn(
|
|
848
|
-
{
|
|
852
|
+
{ stream_id: this.stream_id },
|
|
849
853
|
"event mask failed",
|
|
850
854
|
)
|
|
851
|
-
out = event
|
|
855
|
+
out = claudeMaskFailure(event)
|
|
852
856
|
}
|
|
853
857
|
}
|
|
854
|
-
this.onEvent(out)
|
|
858
|
+
if (out != null) this.onEvent(out)
|
|
855
859
|
}
|
|
856
860
|
|
|
857
861
|
/** セッション終了時にオンディスク jsonl をマスク書き換えする (冪等)。Claude CLI が当該
|
|
@@ -944,18 +948,25 @@ export class ClaudeStreamSession {
|
|
|
944
948
|
/** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
|
|
945
949
|
async _canUseTool(toolName, input) {
|
|
946
950
|
if (!this.onPermission) return { behavior: "allow", updatedInput: input }
|
|
951
|
+
let displayState
|
|
952
|
+
try {
|
|
953
|
+
displayState = MASK_ENABLED ? preparePermissionInput(input, { toolName }) : null
|
|
954
|
+
} catch {
|
|
955
|
+
return { behavior: "deny", message: PERMISSION_MASK_DENIAL }
|
|
956
|
+
}
|
|
947
957
|
const request_id = randomUUID()
|
|
948
958
|
return await new Promise((resolve) => {
|
|
949
959
|
this._permissionResolvers.set(request_id, {
|
|
950
960
|
resolve,
|
|
951
961
|
tool_name: toolName,
|
|
952
962
|
input,
|
|
963
|
+
displayState,
|
|
953
964
|
})
|
|
954
965
|
try {
|
|
955
|
-
this.onPermission({ tool_name: toolName, input, request_id })
|
|
966
|
+
this.onPermission({ tool_name: toolName, input: displayState ? copyPermissionInput(displayState.displayInput) : input, request_id })
|
|
956
967
|
} catch (err) {
|
|
957
968
|
this.logger?.warn(
|
|
958
|
-
{
|
|
969
|
+
{ stream_id: this.stream_id },
|
|
959
970
|
"onPermission callback threw",
|
|
960
971
|
)
|
|
961
972
|
this._permissionResolvers.delete(request_id)
|
|
@@ -1011,6 +1022,16 @@ export class ClaudeStreamSession {
|
|
|
1011
1022
|
this._permissionResolvers.delete(request_id)
|
|
1012
1023
|
// B11: 現 primary 端末からの permission 応答を活性として記録する。
|
|
1013
1024
|
this.touch(this.stream_id)
|
|
1025
|
+
if (!["allow", "deny"].includes(decision?.behavior)) {
|
|
1026
|
+
decision = { behavior: "deny", message: PERMISSION_MASK_DENIAL }
|
|
1027
|
+
}
|
|
1028
|
+
if (MASK_ENABLED && decision?.behavior === "allow" && r.displayState) {
|
|
1029
|
+
try {
|
|
1030
|
+
decision = { ...decision, updatedInput: restorePermissionInput(r.displayState.originalInput, r.displayState.displayInput, decision.updatedInput, { toolName: r.tool_name }) }
|
|
1031
|
+
} catch {
|
|
1032
|
+
decision = { behavior: "deny", message: PERMISSION_MASK_DENIAL }
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1014
1035
|
r.resolve(decision)
|
|
1015
1036
|
return true
|
|
1016
1037
|
}
|
|
@@ -1059,12 +1080,19 @@ export class ClaudeStreamSession {
|
|
|
1059
1080
|
if (!this.onPermission || this._permissionResolvers.size === 0) return
|
|
1060
1081
|
for (const [request_id, r] of this._permissionResolvers) {
|
|
1061
1082
|
try {
|
|
1062
|
-
this.onPermission({ tool_name: r.tool_name, input: r.input, request_id })
|
|
1083
|
+
this.onPermission({ tool_name: r.tool_name, input: r.displayState ? copyPermissionInput(r.displayState.displayInput) : r.input, request_id })
|
|
1063
1084
|
} catch (err) {
|
|
1064
1085
|
this.logger?.warn(
|
|
1065
|
-
{
|
|
1086
|
+
{ stream_id: this.stream_id, request_id },
|
|
1066
1087
|
"replay permission failed",
|
|
1067
1088
|
)
|
|
1089
|
+
this._permissionResolvers.delete(request_id)
|
|
1090
|
+
r.resolve({ behavior: "deny", message: PERMISSION_MASK_DENIAL })
|
|
1091
|
+
try {
|
|
1092
|
+
this.onPermissionCancel?.({ request_ids: [request_id], reason: "replay failed" })
|
|
1093
|
+
} catch {
|
|
1094
|
+
this.logger?.warn({ stream_id: this.stream_id, request_id }, "permission cancel callback failed")
|
|
1095
|
+
}
|
|
1068
1096
|
}
|
|
1069
1097
|
}
|
|
1070
1098
|
}
|
|
@@ -1313,7 +1341,10 @@ export class ClaudeStreamSession {
|
|
|
1313
1341
|
// チップを固着させる。ack を返す条件 (accepted) と同じ場所で 1 回だけ記録する。
|
|
1314
1342
|
const inputId =
|
|
1315
1343
|
typeof opts?.input_id === "string" && opts.input_id ? opts.input_id : null
|
|
1316
|
-
if (inputId && result?.accepted)
|
|
1344
|
+
if (inputId && result?.accepted) {
|
|
1345
|
+
this._recordAcceptedInput(inputId)
|
|
1346
|
+
if (result.preCancelled) this._recordCancelledInput(inputId)
|
|
1347
|
+
}
|
|
1317
1348
|
return result
|
|
1318
1349
|
}
|
|
1319
1350
|
|
|
@@ -1336,7 +1367,7 @@ export class ClaudeStreamSession {
|
|
|
1336
1367
|
{ stream_id: this.stream_id, input_id: inputId },
|
|
1337
1368
|
"dropped pre-canceled input (cancel arrived before input)",
|
|
1338
1369
|
)
|
|
1339
|
-
return { accepted: true }
|
|
1370
|
+
return { accepted: true, preCancelled: true }
|
|
1340
1371
|
}
|
|
1341
1372
|
|
|
1342
1373
|
// ultracode ワンショット (0.6.28): このターンのみ xhigh effort + 常時 dynamic-workflow
|
|
@@ -1713,6 +1744,8 @@ export class ClaudeStreamSession {
|
|
|
1713
1744
|
* snapshot の `accepted` として browser へ載せ、ack 喪失時の自己回復に使う。 */
|
|
1714
1745
|
_recordAcceptedInput(inputId) {
|
|
1715
1746
|
if (typeof inputId !== "string" || !inputId) return
|
|
1747
|
+
const cancelledAt = this._cancelledInputIds.indexOf(inputId)
|
|
1748
|
+
if (cancelledAt !== -1) this._cancelledInputIds.splice(cancelledAt, 1)
|
|
1716
1749
|
const at = this._acceptedInputIds.indexOf(inputId)
|
|
1717
1750
|
if (at !== -1) this._acceptedInputIds.splice(at, 1)
|
|
1718
1751
|
this._acceptedInputIds.push(inputId)
|
|
@@ -1724,6 +1757,19 @@ export class ClaudeStreamSession {
|
|
|
1724
1757
|
}
|
|
1725
1758
|
}
|
|
1726
1759
|
|
|
1760
|
+
_recordCancelledInput(inputId) {
|
|
1761
|
+
if (typeof inputId !== "string" || !inputId) return
|
|
1762
|
+
const at = this._cancelledInputIds.indexOf(inputId)
|
|
1763
|
+
if (at !== -1) this._cancelledInputIds.splice(at, 1)
|
|
1764
|
+
this._cancelledInputIds.push(inputId)
|
|
1765
|
+
if (this._cancelledInputIds.length > ACCEPTED_INPUT_ID_RING_MAX) {
|
|
1766
|
+
this._cancelledInputIds.splice(
|
|
1767
|
+
0,
|
|
1768
|
+
this._cancelledInputIds.length - ACCEPTED_INPUT_ID_RING_MAX,
|
|
1769
|
+
)
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1727
1773
|
/** キャンセル機能 (0.6.26): browser から id 指定で送信待ちメッセージを 1 件取り消す。
|
|
1728
1774
|
* 実行開始済み (既に drain された) メッセージは pending に残っていないので no-op になる。
|
|
1729
1775
|
* 削除に成功したら queue_state を再 emit して送信待ちチップを更新する。 */
|
|
@@ -1740,6 +1786,8 @@ export class ClaudeStreamSession {
|
|
|
1740
1786
|
this._pendingMessages = this._pendingMessages.filter((m) => m.id !== id)
|
|
1741
1787
|
const removed = before !== this._pendingMessages.length
|
|
1742
1788
|
if (removed) {
|
|
1789
|
+
this._canceledInputIds.delete(id)
|
|
1790
|
+
this._recordCancelledInput(id)
|
|
1743
1791
|
this.logger?.info(
|
|
1744
1792
|
{ stream_id: this.stream_id, id, queued: this._pendingMessages.length },
|
|
1745
1793
|
"queued message canceled",
|
|
@@ -1855,6 +1903,7 @@ export class ClaudeStreamSession {
|
|
|
1855
1903
|
// と判定して楽観チップを消し、「キューに無い + ここにも無い = 未配達」なら
|
|
1856
1904
|
// 消さずに再送導線を出す (無言削除の回帰を作らないための 2 軸判定)。
|
|
1857
1905
|
accepted: [...this._acceptedInputIds],
|
|
1906
|
+
cancelled: [...this._cancelledInputIds],
|
|
1858
1907
|
}
|
|
1859
1908
|
}
|
|
1860
1909
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MASK_ENABLED,
|
|
3
|
+
redactSecrets,
|
|
4
|
+
redactSecretsValue,
|
|
5
|
+
toolResultPlaceholder,
|
|
6
|
+
} from "./tool-result-mask.mjs"
|
|
7
|
+
|
|
8
|
+
// On masking failures, keep safe protocol identity/status values so approvals
|
|
9
|
+
// and completion events remain routable. Names alone never exempt secret values.
|
|
10
|
+
const CONTROL_KEYS = new Set([
|
|
11
|
+
"id", "type", "method", "stream_id", "thread_id", "threadId", "turnId",
|
|
12
|
+
"itemId", "request_id", "callId", "input_id", "delegationId", "delegation_id",
|
|
13
|
+
"agentThreadId", "agent_thread_id", "parentThreadId", "ownerTurnId",
|
|
14
|
+
"status", "phase", "kind", "runtime", "success", "exitCode", "code",
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
const FAILURE_TEXT = "[Codex の表示内容を安全にマスクできなかったため非表示にしました]"
|
|
18
|
+
const DISPLAY_KEYS = new Set([
|
|
19
|
+
"arguments", "input", "result", "error", "contentItems", "structuredContent",
|
|
20
|
+
"content", "summary", "text", "prompt", "command", "aggregatedOutput", "changes",
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
function toolInput(value) {
|
|
24
|
+
if (typeof value !== "string") return value
|
|
25
|
+
try { return JSON.parse(value) } catch { return value }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function placeholderForItem(item) {
|
|
29
|
+
if (item.type === "mcpToolCall" || item.type === "dynamicToolCall") {
|
|
30
|
+
return toolResultPlaceholder(item.tool, toolInput(item.arguments))
|
|
31
|
+
}
|
|
32
|
+
if (item.type === "commandExecution") {
|
|
33
|
+
return toolResultPlaceholder("exec_command", { command: item.command })
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SCHEMA_CHILDREN = new Set(["items", "oneOf", "anyOf", "allOf", "not", "additionalProperties"])
|
|
39
|
+
const SCHEMA_RESPONSE_VALUES = new Set(["enum", "const", "required", "id"])
|
|
40
|
+
|
|
41
|
+
/** Field names describe input slots, not supplied credentials. Preserve schema
|
|
42
|
+
* structure while masking labels and removing hidden defaults (never submit a
|
|
43
|
+
* redaction placeholder as an answer). Response keys/choice IDs cannot be renamed
|
|
44
|
+
* without a reverse mapping, so secret-bearing identities disable the form. */
|
|
45
|
+
function maskElicitationSchema(schema, fieldName = "") {
|
|
46
|
+
if (Array.isArray(schema)) return schema.map((child) => maskElicitationSchema(child, fieldName))
|
|
47
|
+
if (!schema || typeof schema !== "object") return redactSecretsValue(schema)
|
|
48
|
+
const entries = []
|
|
49
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
50
|
+
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
51
|
+
entries.push([key, Object.fromEntries(Object.entries(value).map(([name, child]) => {
|
|
52
|
+
if (redactSecrets(name) !== name) throw new Error("secret_schema_identity")
|
|
53
|
+
return [name, maskElicitationSchema(child, name)]
|
|
54
|
+
}))])
|
|
55
|
+
} else if (SCHEMA_CHILDREN.has(key)) {
|
|
56
|
+
entries.push([key, maskElicitationSchema(value, fieldName)])
|
|
57
|
+
} else if (SCHEMA_RESPONSE_VALUES.has(key)) {
|
|
58
|
+
const contextual = fieldName && key !== "required"
|
|
59
|
+
? redactSecretsValue({ [fieldName]: value })[fieldName] : value
|
|
60
|
+
if (contextual !== value || redactSecretsValue(value) !== value) throw new Error("secret_schema_identity")
|
|
61
|
+
entries.push([key, value])
|
|
62
|
+
} else if (key === "default" || key === "examples") {
|
|
63
|
+
const contextKey = fieldName || "default"
|
|
64
|
+
const safe = redactSecretsValue({ [contextKey]: value })
|
|
65
|
+
if (schema.writeOnly !== true && schema.format !== "password" && safe[contextKey] === value) {
|
|
66
|
+
entries.push([key, value])
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// Apply the shared key AND value policy to all other schema metadata.
|
|
70
|
+
entries.push(...Object.entries(redactSecretsValue({ [key]: value })))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return Object.fromEntries(entries)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function preserveElicitationSchema(original, masked) {
|
|
77
|
+
if (original.type !== "codex.permission.request" || original.method !== "mcpServer/elicitation/request"
|
|
78
|
+
|| !original.params?.requestedSchema) return masked
|
|
79
|
+
try {
|
|
80
|
+
return { ...masked, params: { ...masked.params, requestedSchema: maskElicitationSchema(original.params.requestedSchema) } }
|
|
81
|
+
} catch {
|
|
82
|
+
// The existing frontend rejects accept for an unsupported mode, but still
|
|
83
|
+
// offers decline/cancel, addressing the unchanged pending request ID.
|
|
84
|
+
return { ...masked, params: { ...masked.params,
|
|
85
|
+
mode: "masked",
|
|
86
|
+
message: "秘密を含む回答キー・選択値があるため、このフォームは表示できません。拒否またはキャンセルしてください。",
|
|
87
|
+
requestedSchema: {},
|
|
88
|
+
} }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function failedDisplayCopy(value, protocol = true, seen = new WeakSet()) {
|
|
93
|
+
if (!value || typeof value !== "object") return typeof value === "string" ? FAILURE_TEXT : value
|
|
94
|
+
if (seen.has(value)) return FAILURE_TEXT
|
|
95
|
+
seen.add(value)
|
|
96
|
+
const next = Array.isArray(value) ? [] : Object.create(null)
|
|
97
|
+
const keys = new Set()
|
|
98
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
99
|
+
if (!descriptor.enumerable) continue
|
|
100
|
+
const safeKey = Array.isArray(value) ? key : redactSecrets(key)
|
|
101
|
+
if (keys.has(safeKey)) return FAILURE_TEXT
|
|
102
|
+
keys.add(safeKey)
|
|
103
|
+
const raw = descriptor.value
|
|
104
|
+
next[safeKey] = !Object.hasOwn(descriptor, "value") ? FAILURE_TEXT
|
|
105
|
+
: protocol && CONTROL_KEYS.has(key) && (raw === null || typeof raw !== "object")
|
|
106
|
+
? redactSecrets(raw)
|
|
107
|
+
: failedDisplayCopy(raw, protocol && !DISPLAY_KEYS.has(key), seen)
|
|
108
|
+
}
|
|
109
|
+
seen.delete(value)
|
|
110
|
+
return next
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Apply item policy using original arguments, after shared recursive redaction. */
|
|
114
|
+
function maskItems(original, redacted, protocol = true) {
|
|
115
|
+
// Display subtrees have already been sanitized, including object keys and
|
|
116
|
+
// key-collision placeholders. Never rebuild them from the original keys.
|
|
117
|
+
if (!protocol) return redacted
|
|
118
|
+
if (!original || typeof original !== "object") return redacted
|
|
119
|
+
if (!redacted || typeof redacted !== "object") return redacted
|
|
120
|
+
if (Array.isArray(original)) return original.map((value, index) => maskItems(value, redacted[index], protocol))
|
|
121
|
+
const next = Object.fromEntries(Object.entries(redacted).map(([key, masked]) => {
|
|
122
|
+
// A renamed key cannot be a known protocol field; retain its sanitized
|
|
123
|
+
// value directly rather than recovering the old (potentially secret) key.
|
|
124
|
+
if (!Object.hasOwn(original, key)) return [key, masked]
|
|
125
|
+
const value = original[key]
|
|
126
|
+
return [key, maskItems(value, masked, !DISPLAY_KEYS.has(key))]
|
|
127
|
+
}))
|
|
128
|
+
const placeholder = protocol ? placeholderForItem(original) : null
|
|
129
|
+
if (placeholder) {
|
|
130
|
+
// Discard the entire result/error envelope, including provider extensions.
|
|
131
|
+
if (original.result != null) next.result = { content: [{ type: "text", text: placeholder }] }
|
|
132
|
+
if (original.error != null) next.error = { message: placeholder }
|
|
133
|
+
if (original.contentItems != null) next.contentItems = [{ type: "inputText", text: placeholder }]
|
|
134
|
+
if (original.aggregatedOutput != null) next.aggregatedOutput = placeholder
|
|
135
|
+
}
|
|
136
|
+
return next
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Outgoing display copies only. Raw bridge history and model inputs stay untouched. */
|
|
140
|
+
export function maskCodexDisplayPayload(payload) {
|
|
141
|
+
if (!MASK_ENABLED) return payload
|
|
142
|
+
try {
|
|
143
|
+
if (typeof payload?.type !== "string" || !payload.type.startsWith("codex.")) return payload
|
|
144
|
+
const method = payload.event?.method
|
|
145
|
+
// A secret may span arbitrarily many chunks. Completed items are authoritative;
|
|
146
|
+
// do not try regex masking isolated fragments (including future *Delta methods).
|
|
147
|
+
if (typeof method === "string" && /delta$/i.test(method)) return null
|
|
148
|
+
return preserveElicitationSchema(payload, maskItems(payload, redactSecretsValue(payload)))
|
|
149
|
+
} catch {
|
|
150
|
+
// Keep transport routing, but never retry a failed mask with the original body.
|
|
151
|
+
// Failure to inspect a malformed payload also falls back to dropping it.
|
|
152
|
+
try {
|
|
153
|
+
return failedDisplayCopy(payload)
|
|
154
|
+
} catch { return null }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Install once, before any bridge listeners/dispatch sends are registered. */
|
|
159
|
+
export function installCodexDisplayMask(client) {
|
|
160
|
+
const send = client.send.bind(client)
|
|
161
|
+
client.send = (payload, ...args) => {
|
|
162
|
+
const masked = maskCodexDisplayPayload(payload)
|
|
163
|
+
if (!masked) return false
|
|
164
|
+
const event = masked.event
|
|
165
|
+
const item = event?.params?.item
|
|
166
|
+
// Root reasoning is rendered only from deltas by the existing browser. Send
|
|
167
|
+
// one already-masked completed body in that shape; child completion has its
|
|
168
|
+
// own renderer and must not consume the parent's thinking buffer.
|
|
169
|
+
if (MASK_ENABLED && masked.type === "codex.event" && event?.method === "item/completed"
|
|
170
|
+
&& item?.type === "reasoning"
|
|
171
|
+
&& !(item._hubSubagent?.runtime === "codex"
|
|
172
|
+
&& typeof item._hubSubagent.delegationId === "string"
|
|
173
|
+
&& item._hubSubagent.delegationId.trim())) {
|
|
174
|
+
const pick = (value) => Array.isArray(value) ? value.filter((part) => typeof part === "string") : []
|
|
175
|
+
const text = [...pick(item.content), ...pick(item.summary)].join("\n").trim()
|
|
176
|
+
if (text) send({
|
|
177
|
+
...masked,
|
|
178
|
+
event: {
|
|
179
|
+
method: "item/reasoning/summaryTextDelta",
|
|
180
|
+
params: { ...event.params, itemId: item.id, delta: text },
|
|
181
|
+
},
|
|
182
|
+
}, ...args)
|
|
183
|
+
}
|
|
184
|
+
return send(masked, ...args)
|
|
185
|
+
}
|
|
186
|
+
return client
|
|
187
|
+
}
|
|
@@ -973,6 +973,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
973
973
|
queueStateSeq: 0,
|
|
974
974
|
/** ack 喪失からの自己回復用、受理済み browser input_id の bounded ring。 */
|
|
975
975
|
acceptedInputIds: [],
|
|
976
|
+
/** 取消成功 id は accepted(二重実行ガード)から外さず、browser が「accepted +キュー不在」
|
|
977
|
+
* のバブル昇格から除外するための別軸として保持する。 */
|
|
978
|
+
cancelledInputIds: [],
|
|
976
979
|
/** @type {{resolve: Function, timer: any} | null} round3 major#2: turn/start 後に turn/started
|
|
977
980
|
* (activeTurnId 確定) を待つ barrier の待機者。timeout 付きでハングを防ぐ。 */
|
|
978
981
|
_turnActiveWaiter: null,
|
|
@@ -2062,6 +2065,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2062
2065
|
|
|
2063
2066
|
_recordAcceptedInput(session, inputId) {
|
|
2064
2067
|
if (!session || typeof inputId !== "string" || !inputId) return
|
|
2068
|
+
const cancelledIds = session.cancelledInputIds || (session.cancelledInputIds = [])
|
|
2069
|
+
const cancelledAt = cancelledIds.indexOf(inputId)
|
|
2070
|
+
if (cancelledAt !== -1) cancelledIds.splice(cancelledAt, 1)
|
|
2065
2071
|
const ids = session.acceptedInputIds || (session.acceptedInputIds = [])
|
|
2066
2072
|
const at = ids.indexOf(inputId)
|
|
2067
2073
|
if (at !== -1) ids.splice(at, 1)
|
|
@@ -2071,6 +2077,17 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2071
2077
|
}
|
|
2072
2078
|
}
|
|
2073
2079
|
|
|
2080
|
+
_recordCancelledInput(session, inputId) {
|
|
2081
|
+
if (!session || typeof inputId !== "string" || !inputId) return
|
|
2082
|
+
const ids = session.cancelledInputIds || (session.cancelledInputIds = [])
|
|
2083
|
+
const at = ids.indexOf(inputId)
|
|
2084
|
+
if (at !== -1) ids.splice(at, 1)
|
|
2085
|
+
ids.push(inputId)
|
|
2086
|
+
if (ids.length > ACCEPTED_INPUT_ID_RING_MAX) {
|
|
2087
|
+
ids.splice(0, ids.length - ACCEPTED_INPUT_ID_RING_MAX)
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2074
2091
|
/**
|
|
2075
2092
|
* 重複ガードの台帳から id を取り消す (2026-08-05)。
|
|
2076
2093
|
*
|
|
@@ -2103,6 +2120,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2103
2120
|
seq: session.queueStateSeq || 0,
|
|
2104
2121
|
generation: session.seq,
|
|
2105
2122
|
accepted: [...(session.acceptedInputIds || [])],
|
|
2123
|
+
cancelled: [...(session.cancelledInputIds || [])],
|
|
2106
2124
|
}
|
|
2107
2125
|
}
|
|
2108
2126
|
|
|
@@ -2121,6 +2139,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2121
2139
|
return false
|
|
2122
2140
|
}
|
|
2123
2141
|
const [item] = session.inputQueue.splice(index, 1)
|
|
2142
|
+
this._recordCancelledInput(session, item.inputId || item.deliveryId)
|
|
2124
2143
|
if (!item.acceptedAsQueued) {
|
|
2125
2144
|
const err = Object.assign(new Error("codex queued input cancelled"), {
|
|
2126
2145
|
reason: "cancelled",
|
package/src/main.mjs
CHANGED
|
@@ -48,8 +48,10 @@ import { WsClient } from "./ws-client.mjs"
|
|
|
48
48
|
import { DispatchScheduler } from "./dispatch-scheduler.mjs"
|
|
49
49
|
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
50
50
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
51
|
+
import { installClaudeDisplayMask } from "./claude-display-mask.mjs"
|
|
51
52
|
import { CodexMcpControl } from "./codex-mcp-control.mjs"
|
|
52
53
|
import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
|
|
54
|
+
import { installCodexDisplayMask } from "./codex-result-mask.mjs"
|
|
53
55
|
import {
|
|
54
56
|
quotePosixShellArg,
|
|
55
57
|
resolveCodexBinary,
|
|
@@ -999,7 +1001,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
999
1001
|
)
|
|
1000
1002
|
}
|
|
1001
1003
|
|
|
1002
|
-
const client = new WsClient(config, {
|
|
1004
|
+
const client = installCodexDisplayMask(new WsClient(config, {
|
|
1003
1005
|
logger,
|
|
1004
1006
|
version,
|
|
1005
1007
|
hostKey,
|
|
@@ -1009,7 +1011,8 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
1009
1011
|
bundleVersionProvider: readBundleVersionSync,
|
|
1010
1012
|
bundleStatusProvider,
|
|
1011
1013
|
bundleManifestPath: BUNDLE_MANIFEST_PATH,
|
|
1012
|
-
})
|
|
1014
|
+
}))
|
|
1015
|
+
installClaudeDisplayMask(client)
|
|
1013
1016
|
|
|
1014
1017
|
// EventEmitter の 'error' は listener が無いと process が落ちる。
|
|
1015
1018
|
// ws-client は close 側で reconnect を予約しているので、ここでは log だけ。
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util"
|
|
2
|
+
import { redactSecrets, redactSecretsValue, SECRET_PLACEHOLDER } from "./tool-result-mask.mjs"
|
|
3
|
+
|
|
4
|
+
export const PERMISSION_MASK_DENIAL =
|
|
5
|
+
"伏せ字を含む引数を復元できません。該当項目を再入力して、もう一度実行してください。"
|
|
6
|
+
|
|
7
|
+
/** Permission payloads must be JSON values, without cycles or accessors. */
|
|
8
|
+
export function copyPermissionInput(value, ancestors = new Set()) {
|
|
9
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
|
10
|
+
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
11
|
+
if (!value || typeof value !== "object" || ancestors.has(value)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
12
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error(PERMISSION_MASK_DENIAL)
|
|
13
|
+
ancestors.add(value)
|
|
14
|
+
try {
|
|
15
|
+
if (Object.getOwnPropertySymbols(value).length) throw new Error(PERMISSION_MASK_DENIAL)
|
|
16
|
+
const entries = Object.entries(Object.getOwnPropertyDescriptors(value))
|
|
17
|
+
.filter(([key]) => !(Array.isArray(value) && key === "length"))
|
|
18
|
+
.map(([key, descriptor]) => {
|
|
19
|
+
if (!descriptor.enumerable || !Object.hasOwn(descriptor, "value")) throw new Error(PERMISSION_MASK_DENIAL)
|
|
20
|
+
return [key, copyPermissionInput(descriptor.value, ancestors)]
|
|
21
|
+
})
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
if (entries.length !== value.length || entries.some(([key], index) => key !== String(index))) throw new Error(PERMISSION_MASK_DENIAL)
|
|
24
|
+
return entries.map(([, item]) => item)
|
|
25
|
+
}
|
|
26
|
+
return Object.fromEntries(entries)
|
|
27
|
+
} finally {
|
|
28
|
+
ancestors.delete(value)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function questionKeyMap(originalInput, displayInput) {
|
|
33
|
+
const keys = new Map()
|
|
34
|
+
if (!Array.isArray(originalInput.questions) || !Array.isArray(displayInput.questions)) return keys
|
|
35
|
+
originalInput.questions.forEach((question, index) => {
|
|
36
|
+
const raw = question?.question
|
|
37
|
+
const shown = displayInput.questions[index]?.question
|
|
38
|
+
if (typeof raw !== "string" || typeof shown !== "string") return
|
|
39
|
+
if (keys.has(shown) && keys.get(shown) !== raw) throw new Error(PERMISSION_MASK_DENIAL)
|
|
40
|
+
keys.set(shown, raw)
|
|
41
|
+
})
|
|
42
|
+
return keys
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function questionLabelMaps(originalInput, displayInput) {
|
|
46
|
+
const questions = new Map()
|
|
47
|
+
if (!Array.isArray(originalInput.questions) || !Array.isArray(displayInput.questions)) return questions
|
|
48
|
+
originalInput.questions.forEach((question, index) => {
|
|
49
|
+
if (typeof question?.question !== "string" || !Array.isArray(question.options)) return
|
|
50
|
+
const labels = questions.get(question.question) ?? new Map()
|
|
51
|
+
const shownOptions = displayInput.questions[index]?.options
|
|
52
|
+
question.options.forEach((option, optionIndex) => {
|
|
53
|
+
const raw = option?.label
|
|
54
|
+
const shown = shownOptions?.[optionIndex]?.label
|
|
55
|
+
if (typeof raw !== "string" || typeof shown !== "string") return
|
|
56
|
+
if (labels.has(shown) && labels.get(shown) !== raw) throw new Error(PERMISSION_MASK_DENIAL)
|
|
57
|
+
labels.set(shown, raw)
|
|
58
|
+
})
|
|
59
|
+
questions.set(question.question, labels)
|
|
60
|
+
})
|
|
61
|
+
return questions
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function preparePermissionInput(input, { toolName } = {}) {
|
|
65
|
+
const originalInput = copyPermissionInput(input)
|
|
66
|
+
if (!originalInput || Array.isArray(originalInput) || typeof originalInput !== "object") throw new Error(PERMISSION_MASK_DENIAL)
|
|
67
|
+
const displayInput = copyPermissionInput(redactSecretsValue(originalInput))
|
|
68
|
+
if (!displayInput || typeof displayInput !== "object" || Array.isArray(displayInput)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
69
|
+
// AskUserQuestion's UI indexes answers by the displayed question text.
|
|
70
|
+
// Reject ambiguous display labels before the user is asked to choose.
|
|
71
|
+
if (toolName === "AskUserQuestion") {
|
|
72
|
+
questionKeyMap(originalInput, displayInput)
|
|
73
|
+
questionLabelMaps(originalInput, displayInput)
|
|
74
|
+
}
|
|
75
|
+
return { originalInput, displayInput }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Restore only unchanged masked values at the same path. Never interpolate secrets. */
|
|
79
|
+
export function restorePermissionInput(originalInput, displayInput, updatedInput, { toolName } = {}) {
|
|
80
|
+
const updated = copyPermissionInput(updatedInput)
|
|
81
|
+
if (!updated || Array.isArray(updated) || typeof updated !== "object") throw new Error(PERMISSION_MASK_DENIAL)
|
|
82
|
+
function containsMarker(value) {
|
|
83
|
+
if (typeof value === "string") return value.includes(SECRET_PLACEHOLDER)
|
|
84
|
+
return value && typeof value === "object" && Object.entries(value).some(([key, item]) => key.includes(SECRET_PLACEHOLDER) || containsMarker(item))
|
|
85
|
+
}
|
|
86
|
+
function restore(original, display, next, path = []) {
|
|
87
|
+
const wasMasked = !isDeepStrictEqual(original, display)
|
|
88
|
+
if (wasMasked && isDeepStrictEqual(next, display)) return copyPermissionInput(original)
|
|
89
|
+
if (wasMasked && (typeof next === "string" || typeof display === "string") && containsMarker(next)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
90
|
+
if (!next || typeof next !== "object") return next
|
|
91
|
+
// A wholly hidden object has no editable key correspondence. A complete
|
|
92
|
+
// replacement without markers is ordinary new input, not a restoration.
|
|
93
|
+
if (wasMasked && typeof display === "string") return next
|
|
94
|
+
const keyMap = new Map()
|
|
95
|
+
if (original && typeof original === "object" && display && typeof display === "object") {
|
|
96
|
+
for (const key of Object.keys(original)) {
|
|
97
|
+
const shown = Array.isArray(original) ? key : redactSecrets(key)
|
|
98
|
+
if (keyMap.has(shown)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
99
|
+
keyMap.set(shown, key)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const hasMaskedKeys = [...keyMap].some(([shown, raw]) => shown !== raw)
|
|
103
|
+
const destinations = new Set()
|
|
104
|
+
const entries = Object.entries(next).map(([key, value]) => {
|
|
105
|
+
const originalKey = keyMap.get(key) ?? key
|
|
106
|
+
const questionAnswerKey = toolName === "AskUserQuestion" && path.length === 1 && path[0] === "answers"
|
|
107
|
+
if (hasMaskedKeys && key.includes(SECRET_PLACEHOLDER) && !keyMap.has(key) && !questionAnswerKey) throw new Error(PERMISSION_MASK_DENIAL)
|
|
108
|
+
if (destinations.has(originalKey)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
109
|
+
destinations.add(originalKey)
|
|
110
|
+
return [originalKey, restore(
|
|
111
|
+
original && typeof original === "object" && Object.hasOwn(original, originalKey) ? original[originalKey] : undefined,
|
|
112
|
+
display && typeof display === "object" && Object.hasOwn(display, key) ? display[key] : undefined,
|
|
113
|
+
value, [...path, originalKey],
|
|
114
|
+
)]
|
|
115
|
+
})
|
|
116
|
+
return Array.isArray(next) ? entries.map(([, value]) => value) : Object.fromEntries(entries)
|
|
117
|
+
}
|
|
118
|
+
const restored = restore(originalInput, displayInput, updated)
|
|
119
|
+
if (toolName === "AskUserQuestion" && Object.hasOwn(restored, "answers")) {
|
|
120
|
+
if (!restored.answers || typeof restored.answers !== "object" || Array.isArray(restored.answers)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
121
|
+
const keys = questionKeyMap(originalInput, displayInput)
|
|
122
|
+
const questionLabels = questionLabelMaps(originalInput, displayInput)
|
|
123
|
+
const answers = new Map()
|
|
124
|
+
for (const [key, value] of Object.entries(restored.answers)) {
|
|
125
|
+
const originalKey = keys.get(key) ?? key
|
|
126
|
+
if (key.includes(SECRET_PLACEHOLDER) && !keys.has(key) && !Object.values(originalInput.questions ?? []).some(q => q?.question === key)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
127
|
+
if (answers.has(originalKey)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
128
|
+
const labels = questionLabels.get(originalKey)
|
|
129
|
+
const restoreLabel = (answer) => {
|
|
130
|
+
if (typeof answer !== "string") throw new Error(PERMISSION_MASK_DENIAL)
|
|
131
|
+
// The UI returns either a selected label or free input in this field.
|
|
132
|
+
// Only a complete, unique label match is interpretable as that option.
|
|
133
|
+
if (labels?.has(answer)) return labels.get(answer)
|
|
134
|
+
if (answer.includes(SECRET_PLACEHOLDER)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
135
|
+
return answer
|
|
136
|
+
}
|
|
137
|
+
answers.set(originalKey, Array.isArray(value) ? value.map(restoreLabel) : restoreLabel(value))
|
|
138
|
+
}
|
|
139
|
+
restored.answers = Object.fromEntries(answers)
|
|
140
|
+
}
|
|
141
|
+
return restored
|
|
142
|
+
}
|
package/src/tool-result-mask.mjs
CHANGED
|
@@ -8,23 +8,20 @@
|
|
|
8
8
|
* jsonl へ流れていた。さらに Hub ナレッジに格納した **非公開スキルプロンプト本文**
|
|
9
9
|
* (`get_skill_prompt` MCP の戻り値) も毎回リザルトに露出していた。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
81
|
+
* キーの完全な構成要素で判定し、短い値も対象にする。引用符は維持する。
|
|
83
82
|
*/
|
|
84
83
|
const ASSIGN_PATTERN =
|
|
85
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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 =
|
|
395
|
+
const masked = maskAtRestMessage(obj, toolNames)
|
|
325
396
|
if (masked !== obj) {
|
|
326
397
|
changed = true
|
|
327
398
|
return JSON.stringify(masked)
|