feat(claude): OpenCode-local parity — permission bridge + Ask mode - #230
Conversation
Spec for on-device Claude stability, PermissionRequest hook bridge for per-tool approvals/questions, and remaining surface gaps.
Add file-bridge + guest hook so Claude Code can use Ask mode with the existing Android permission UI, wire sessionDiff via workspace git, and provision jq/hook on install.
|
🔍 OpenCodeReview found 9 issue(s) in this PR.
|
| '{v:1,kind:$kind,requestId:$requestId,androidSessionId:$androidSessionId,claudeSessionId:$claudeSessionId,toolName:$toolName,toolInput:$toolInput,permissionLabel:$permissionLabel,createdAtMs:$createdAtMs}' \ | ||
| >"$REQUEST_FILE" | ||
| else | ||
| printf '%s\n' "{\"v\":1,\"kind\":\"$KIND\",\"requestId\":\"$REQUEST_ID\",\"androidSessionId\":\"$ANDROID_SESSION\",\"claudeSessionId\":\"$SESSION_ID\",\"toolName\":\"$TOOL_NAME\",\"toolInput\":{},\"permissionLabel\":\"$LABEL\",\"createdAtMs\":0}" >"$REQUEST_FILE" |
There was a problem hiding this comment.
[security · low]
フォールバックの JSON 生成は $KIND / $REQUEST_ID / $ANDROID_SESSION / $TOOL_NAME / $LABEL を無エスケープで文字列連結して書き出しています。値に \ や二重引用符・改行が含まれると、無効な JSON がブリッジ配下に置かれ、Android 側の ClaudePermissionBridge が StoredRequest を復元できず権限確認フローが破綻します。sed 等でエスケープしてから埋め込むようにしてください。
Suggestion:
| printf '%s\n' "{\"v\":1,\"kind\":\"$KIND\",\"requestId\":\"$REQUEST_ID\",\"androidSessionId\":\"$ANDROID_SESSION\",\"claudeSessionId\":\"$SESSION_ID\",\"toolName\":\"$TOOL_NAME\",\"toolInput\":{},\"permissionLabel\":\"$LABEL\",\"createdAtMs\":0}" >"$REQUEST_FILE" | |
| LABEL_ESC=$(printf '%s' "$LABEL" | sed 's/\\/\\\\/g; s/\"/\\"/g') | |
| printf '%s\n' "{\"v\":1,\"kind\":\"$KIND\",\"requestId\":\"$REQUEST_ID\",\"androidSessionId\":\"$ANDROID_SESSION\",\"claudeSessionId\":\"$SESSION_ID\",\"toolName\":\"$TOOL_NAME\",\"toolInput\":{},\"permissionLabel\":\"$LABEL_ESC\",\"createdAtMs\":0}" >"$REQUEST_FILE" |
| DECISION=$(sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$RESPONSE_FILE" | head -n1) | ||
| MESSAGE="" | ||
| UPDATED="" |
There was a problem hiding this comment.
[bug · medium]
jq が存在しないフォールバック分岐では UPDATED と MESSAGE が常に空になり、AskUserQuestion(answerQuestion)のように Android 側が updatedInput(回答情報)ごと allow を返しても、その更新内容が Claude Code に渡されません。また TOOL_INPUT='{}' のため、Android に渡るリクエストにはツール入力が一切含まれず、ユーザーが内容を確認できないまま許可する形になります。フォールバックは sed のみで JSON を動かしているため、Python 等の軽量パーサーを用意して updatedInput を扱えるように構成し直すことを推奨します。
| # shellcheck disable=SC2039 | ||
| sleep "$SLEEP_SEC" 2>/dev/null || sleep 1 | ||
| elapsed=$((elapsed + 1)) |
There was a problem hiding this comment.
[bug · high]
elapsed は実際の経過時間ではなくループ回数を数えており、SLEEP_SEC=0.25(既定値)では TIMEOUT_SEC=300 の実効待機時間が約75秒になります。ANDCODE_PERMISSION_TIMEOUT_SEC が「秒」として300秒を意味するのに対し、4倍早くタイムアウト判定され user が承認する前に deny(User did not respond in time)が返ってしまいます。date +%s で実経過時間を計測する形に修正してください。
Suggestion:
| # shellcheck disable=SC2039 | |
| sleep "$SLEEP_SEC" 2>/dev/null || sleep 1 | |
| elapsed=$((elapsed + 1)) | |
| # shellcheck disable=SC2039 | |
| sleep "$SLEEP_SEC" 2>/dev/null || sleep 1 | |
| elapsed=$(( $(date +%s) - start_ts )) |
| elapsed=$((elapsed + 1)) | ||
| done | ||
|
|
||
| rm -f "$REQUEST_FILE" 2>/dev/null || true |
There was a problem hiding this comment.
[other · low]
タイムアウト時は REQUEST_FILE のみ削除し RESPONSE_FILE を残しています。タイムアウト直後(Hook 終了後)に Android 側が応答を書き込んだ場合、それを読み取る主体が存在せず、responses ディレクトリにファイルが蓄積し続けます。タイムアウト時の後処理でも RESPONSE_FILE まで削除(または応答ファイル全体の定期的な掃除)を検討してください。
Suggestion:
| rm -f "$REQUEST_FILE" 2>/dev/null || true | |
| rm -f "$REQUEST_FILE" "$RESPONSE_FILE" 2>/dev/null || true |
| fun respondToPermission( | ||
| permissionId: String, | ||
| response: PermissionResponse, | ||
| remember: Boolean, | ||
| ): Boolean = permissionBridge.respond(permissionId, response, remember) |
There was a problem hiding this comment.
[security · high]
respondToPermission / answerQuestion は受け取った permissionId / requestId をそのまま ClaudePermissionBridge へ渡している。ブリッジ側は File(pendingDir, "$requestId.json") / File(responsesDir, "$requestId.json") のように識別子をパスに連結しており、requestId に ../ やパス区切りを含む値が届くとブリッジ管理ディレクトリ外のファイルを読み書きできる可能性がある(パストラバーサル)。requestId はゲスト側フックが生成する UUID である前提だが、防御的にこの境界で UUID 書式(正規表現による検証)や識別子排除を行うべき。Sanitize の追加と、不正 ID への応答を拒否する実装を推奨する。
| private fun ensureBridgeWatcher() { | ||
| if (bridgeWatchJob?.isActive == true) return | ||
| bridgeWatchJob = | ||
| scope.launch { | ||
| while (isActive) { | ||
| permissionBridge.pollPending().forEach { request -> |
There was a problem hiding this comment.
[bug · medium]
ensureBridgeWatcher() で start されたポーリングジョブは、既存の stopAll()/disconnect() でどこからもキャンセルされていない。stopAll() は session のプロセスのみ終了し、bridgeWatchJob はセッションが全て停止しても 250ms 間隔で動き続ける。また exceptions による終了時の再開もないため、I/O 例外等で while ループが終了すると以後の PermissionRequest が通知されなくなる。stopAll()(または dispose)で bridgeWatchJob?.cancel() し、ループ内は try/catch で例外時も継続して監視するよう堅牢化することを推奨する。
| val effectiveMode = | ||
| if (permissionMode.requiresBridge && !ClaudePermissionHooks.isInstalled(runtime.rootfs)) { | ||
| ClaudePermissionMode.ACCEPT_EDITS | ||
| } else { | ||
| permissionMode | ||
| } |
There was a problem hiding this comment.
[bug · high]
ユーザーが明示的に ASK(requiresBridge、全操作を都度承認)を選んでいても、フック未導入の rootfs では無条件に ACCEPT_EDITS へ差し替えられる。ACCEPT_EDITS は allowedTools に Bash を含むため、承認ダイアログを期待していた操作(コマンド実行など)が自動承認される。フックが導入できない場合のフォールバックは「動作がハングしない」という利便性のための設計だが、ユーザーの選択した権限ポリシーが静かに緩和される点は安全性の観点で問題がある。差し替えをUIに通知するか、ASK 選択時にフック未導入なら起動を拒否する、あるいは isInstalled の結果を permissionBridgeReady() 経由でUIに見せて選択不可にする等の対策を推奨する。
| } | ||
|
|
||
| /** Installs the Claude Code PermissionRequest hook into an Alpine rootfs. */ | ||
| fun provisionClaudePermissionHook(rootfs: File = File(runtimeDirectory, "environment/rootfs")) { |
There was a problem hiding this comment.
[maintainability · low]
この関数はクラス内(install と provisionBrowserMcpForExistingInstall)からのみ呼び出されており、同種の処理である provisionBrowserMcp が private であるのに対し public で公開されています。またデフォルト引数はどの呼び出し元でも使われていません。private に変更し、不要なデフォルト引数を削除して API サーフェスを絞ることを推奨します。
Suggestion:
| fun provisionClaudePermissionHook(rootfs: File = File(runtimeDirectory, "environment/rootfs")) { | |
| private fun provisionClaudePermissionHook(rootfs: File) { |
| runCatching { | ||
| val script = | ||
| context.assets.open("scripts/and-code-claude-permission-hook.sh").bufferedReader().use { it.readText() } | ||
| ClaudePermissionHooks.installInto(rootfs, script) | ||
| } |
There was a problem hiding this comment.
[maintainability · medium]
runCatching が例外を握りつぶすうえ、ClaudePermissionHooks.installInto() の成功可否を示す Boolean 戻り値も破棄されています。installInto 自体も内部で runCatching し Boolean を返すため、スクリプトの書き込み失敗やアセット欠落などでフックが入らなくても完全に無音で素通りし、パーミッション承認機能が無効化されても原因の切り分けが困難になります。失敗時にログを出す、または戻り値を確認して失敗を伝播させることを推奨します。
Suggestion:
| runCatching { | |
| val script = | |
| context.assets.open("scripts/and-code-claude-permission-hook.sh").bufferedReader().use { it.readText() } | |
| ClaudePermissionHooks.installInto(rootfs, script) | |
| } | |
| val installed = | |
| runCatching { | |
| val script = | |
| context.assets.open("scripts/and-code-claude-permission-hook.sh").bufferedReader().use { it.readText() } | |
| ClaudePermissionHooks.installInto(rootfs, script) | |
| }.getOrDefault(false) | |
| if (!installed) { | |
| // 失敗をログに残す(例: Log.w(TAG, "Failed to provision Claude Code permission hook into $rootfs")) | |
| } |
Summary
defaultCLI); Accept edits remains the safe default until opted injq; capabilities.permissions/questions when bridge readyDesign
docs/superpowers/specs/2026-08-08-claude-code-opencode-local-parity-design.mddocs/superpowers/plans/2026-08-08-claude-code-opencode-local-parity.mdTest plan
ClaudePermissionBridgeTest,ClaudePermissionModeTest,ClaudeCodeInstallerTestprovisionBrowserMcpForExistingInstallpath)