@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.
@@ -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
+ }
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
+ }