Skip to content

feat(claude): OpenCode-local parity — permission bridge + Ask mode - #230

Merged
yuga-hashimoto merged 2 commits into
mainfrom
feat/claude-code-opencode-parity-design
Aug 8, 2026
Merged

feat(claude): OpenCode-local parity — permission bridge + Ask mode#230
yuga-hashimoto merged 2 commits into
mainfrom
feat/claude-code-opencode-parity-design

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

  • Claude Code PermissionRequest hook bridge so per-tool approvals and questions use the existing Android UI/notifications
  • New Ask each time permission mode (default CLI); Accept edits remains the safe default until opted in
  • sessionDiff via workspace git; install provisions hook + jq; capabilities.permissions/questions when bridge ready

Design

  • Spec: docs/superpowers/specs/2026-08-08-claude-code-opencode-local-parity-design.md
  • Plan: docs/superpowers/plans/2026-08-08-claude-code-opencode-local-parity.md

Test plan

  • Unit: ClaudePermissionBridgeTest, ClaudePermissionModeTest, ClaudeCodeInstallerTest
  • spotlessCheck
  • Device: Ask mode → Bash prompt → Allow/Reject/Always + notification
  • Existing Accept edits / Full access unchanged
  • Re-open app after Claude install re-provisions hook (provisionBrowserMcpForExistingInstall path)

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.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 9 issue(s) in this PR.

  • ✅ Successfully posted inline: 9 comment(s)

'{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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · low]
フォールバックの JSON 生成は $KIND / $REQUEST_ID / $ANDROID_SESSION / $TOOL_NAME / $LABEL を無エスケープで文字列連結して書き出しています。値に \ や二重引用符・改行が含まれると、無効な JSON がブリッジ配下に置かれ、Android 側の ClaudePermissionBridgeStoredRequest を復元できず権限確認フローが破綻します。sed 等でエスケープしてから埋め込むようにしてください。

Suggestion:

Suggested change
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"

Comment on lines +99 to +101
DECISION=$(sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$RESPONSE_FILE" | head -n1)
MESSAGE=""
UPDATED=""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
jq が存在しないフォールバック分岐では UPDATEDMESSAGE が常に空になり、AskUserQuestion(answerQuestion)のように Android 側が updatedInput(回答情報)ごと allow を返しても、その更新内容が Claude Code に渡されません。また TOOL_INPUT='{}' のため、Android に渡るリクエストにはツール入力が一切含まれず、ユーザーが内容を確認できないまま許可する形になります。フォールバックは sed のみで JSON を動かしているため、Python 等の軽量パーサーを用意して updatedInput を扱えるように構成し直すことを推奨します。

Comment on lines +126 to +128
# shellcheck disable=SC2039
sleep "$SLEEP_SEC" 2>/dev/null || sleep 1
elapsed=$((elapsed + 1))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · low]
タイムアウト時は REQUEST_FILE のみ削除し RESPONSE_FILE を残しています。タイムアウト直後(Hook 終了後)に Android 側が応答を書き込んだ場合、それを読み取る主体が存在せず、responses ディレクトリにファイルが蓄積し続けます。タイムアウト時の後処理でも RESPONSE_FILE まで削除(または応答ファイル全体の定期的な掃除)を検討してください。

Suggestion:

Suggested change
rm -f "$REQUEST_FILE" 2>/dev/null || true
rm -f "$REQUEST_FILE" "$RESPONSE_FILE" 2>/dev/null || true

Comment on lines +194 to +198
fun respondToPermission(
permissionId: String,
response: PermissionResponse,
remember: Boolean,
): Boolean = permissionBridge.respond(permissionId, response, remember)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · high]
respondToPermission / answerQuestion は受け取った permissionId / requestId をそのまま ClaudePermissionBridge へ渡している。ブリッジ側は File(pendingDir, "$requestId.json") / File(responsesDir, "$requestId.json") のように識別子をパスに連結しており、requestId に ../ やパス区切りを含む値が届くとブリッジ管理ディレクトリ外のファイルを読み書きできる可能性がある(パストラバーサル)。requestId はゲスト側フックが生成する UUID である前提だが、防御的にこの境界で UUID 書式(正規表現による検証)や識別子排除を行うべき。Sanitize の追加と、不正 ID への応答を拒否する実装を推奨する。

Comment on lines +218 to +223
private fun ensureBridgeWatcher() {
if (bridgeWatchJob?.isActive == true) return
bridgeWatchJob =
scope.launch {
while (isActive) {
permissionBridge.pollPending().forEach { request ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
ensureBridgeWatcher() で start されたポーリングジョブは、既存の stopAll()/disconnect() でどこからもキャンセルされていない。stopAll() は session のプロセスのみ終了し、bridgeWatchJob はセッションが全て停止しても 250ms 間隔で動き続ける。また exceptions による終了時の再開もないため、I/O 例外等で while ループが終了すると以後の PermissionRequest が通知されなくなる。stopAll()(または dispose)で bridgeWatchJob?.cancel() し、ループ内は try/catch で例外時も継続して監視するよう堅牢化することを推奨する。

Comment on lines +336 to +341
val effectiveMode =
if (permissionMode.requiresBridge && !ClaudePermissionHooks.isInstalled(runtime.rootfs)) {
ClaudePermissionMode.ACCEPT_EDITS
} else {
permissionMode
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
この関数はクラス内(installprovisionBrowserMcpForExistingInstall)からのみ呼び出されており、同種の処理である provisionBrowserMcpprivate であるのに対し public で公開されています。またデフォルト引数はどの呼び出し元でも使われていません。private に変更し、不要なデフォルト引数を削除して API サーフェスを絞ることを推奨します。

Suggestion:

Suggested change
fun provisionClaudePermissionHook(rootfs: File = File(runtimeDirectory, "environment/rootfs")) {
private fun provisionClaudePermissionHook(rootfs: File) {

Comment on lines +462 to +466
runCatching {
val script =
context.assets.open("scripts/and-code-claude-permission-hook.sh").bufferedReader().use { it.readText() }
ClaudePermissionHooks.installInto(rootfs, script)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · medium]
runCatching が例外を握りつぶすうえ、ClaudePermissionHooks.installInto() の成功可否を示す Boolean 戻り値も破棄されています。installInto 自体も内部で runCatching し Boolean を返すため、スクリプトの書き込み失敗やアセット欠落などでフックが入らなくても完全に無音で素通りし、パーミッション承認機能が無効化されても原因の切り分けが困難になります。失敗時にログを出す、または戻り値を確認して失敗を伝播させることを推奨します。

Suggestion:

Suggested change
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"))
}

@yuga-hashimoto
yuga-hashimoto merged commit 4c7cf51 into main Aug 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant