Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions app/src/main/assets/scripts/and-code-claude-permission-hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/bin/sh
# AndCode PermissionRequest hook for Claude Code.
# Reads hook JSON on stdin, asks the Android app via the file bridge, prints a decision.

set -eu

BRIDGE="${ANDCODE_CLAUDE_BRIDGE:-/root/.andcode/claude-bridge}"
PENDING="$BRIDGE/pending"
RESPONSES="$BRIDGE/responses"
ALWAYS="$BRIDGE/always-rules.json"
TIMEOUT_SEC="${ANDCODE_PERMISSION_TIMEOUT_SEC:-300}"
SLEEP_SEC=0.25

mkdir -p "$PENDING" "$RESPONSES"

INPUT=$(cat)
if [ -z "$INPUT" ]; then
exit 0
fi

# Prefer jq; fall back to a tiny python helper when present.
if command -v jq >/dev/null 2>&1; then
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // .toolName // empty')
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // .sessionId // empty')
TOOL_INPUT=$(printf '%s' "$INPUT" | jq -c '.tool_input // .toolInput // {}')
HOOK_EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // .hookEventName // "PermissionRequest"')
else
TOOL_NAME=$(printf '%s' "$INPUT" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
TOOL_INPUT='{}'
HOOK_EVENT="PermissionRequest"
fi

if [ -z "$TOOL_NAME" ]; then
TOOL_NAME="Tool"
fi

# Auto-allow remembered rules without waking the UI.
if [ -f "$ALWAYS" ] && command -v jq >/dev/null 2>&1; then
CMD=$(printf '%s' "$TOOL_INPUT" | jq -r '.command // empty')
MATCH=$(jq -r --arg t "$TOOL_NAME" --arg c "$CMD" '
.rules[]? | select(.toolName == $t) |
if (.commandPrefix == null or .commandPrefix == "") then "yes"
elif ($c | startswith(.commandPrefix)) then "yes"
else empty end
' "$ALWAYS" 2>/dev/null | head -n1 || true)
if [ "$MATCH" = "yes" ]; then
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"AndCode always-allow rule\"}}"
exit 0
fi
fi

KIND="permission"
if [ "$TOOL_NAME" = "AskUserQuestion" ]; then
KIND="question"
fi

REQUEST_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || date +%s%N)
ANDROID_SESSION="${ANDCODE_ANDROID_SESSION_ID:-$SESSION_ID}"
if [ -z "$ANDROID_SESSION" ]; then
ANDROID_SESSION="unknown"
fi

LABEL="$TOOL_NAME"
if command -v jq >/dev/null 2>&1; then
DESC=$(printf '%s' "$TOOL_INPUT" | jq -r '.description // .command // empty' 2>/dev/null || true)
if [ -n "$DESC" ]; then
LABEL="$TOOL_NAME: $DESC"
fi
fi

REQUEST_FILE="$PENDING/$REQUEST_ID.json"
RESPONSE_FILE="$RESPONSES/$REQUEST_ID.json"

if command -v jq >/dev/null 2>&1; then
jq -n \
--arg kind "$KIND" \
--arg requestId "$REQUEST_ID" \
--arg androidSessionId "$ANDROID_SESSION" \
--arg claudeSessionId "$SESSION_ID" \
--arg toolName "$TOOL_NAME" \
--arg permissionLabel "$LABEL" \
--argjson toolInput "$TOOL_INPUT" \
--argjson createdAtMs "$(date +%s000)" \
'{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"

fi

elapsed=0
while [ "$elapsed" -lt "$TIMEOUT_SEC" ]; do
if [ -f "$RESPONSE_FILE" ]; then
if command -v jq >/dev/null 2>&1; then
DECISION=$(jq -r '.decision // "deny"' "$RESPONSE_FILE")
MESSAGE=$(jq -r '.message // empty' "$RESPONSE_FILE")
UPDATED=$(jq -c '.updatedInput // empty' "$RESPONSE_FILE")
else
DECISION=$(sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$RESPONSE_FILE" | head -n1)
MESSAGE=""
UPDATED=""
Comment on lines +99 to +101

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 を扱えるように構成し直すことを推奨します。

fi
rm -f "$REQUEST_FILE" "$RESPONSE_FILE" 2>/dev/null || true
if [ "$DECISION" = "allow" ]; then
if [ -n "$UPDATED" ] && [ "$UPDATED" != "null" ] && [ "$UPDATED" != "" ]; then
if command -v jq >/dev/null 2>&1; then
jq -n --argjson updated "$UPDATED" \
'{hookSpecificOutput:{hookEventName:"PermissionRequest",permissionDecision:"allow",updatedInput:$updated}}'
else
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","permissionDecision":"allow"}}'
fi
else
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","permissionDecision":"allow"}}'
fi
exit 0
fi
REASON=${MESSAGE:-User rejected this action}
if command -v jq >/dev/null 2>&1; then
jq -n --arg reason "$REASON" \
'{hookSpecificOutput:{hookEventName:"PermissionRequest",permissionDecision:"deny",permissionDecisionReason:$reason}}'
else
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
fi
exit 0
fi
# shellcheck disable=SC2039
sleep "$SLEEP_SEC" 2>/dev/null || sleep 1
elapsed=$((elapsed + 1))
Comment on lines +126 to +128

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 ))

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

printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","permissionDecision":"deny","permissionDecisionReason":"User did not respond in time"}}'
exit 0
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ object ClaudeCodeInstaller {
) = """
$apk update
${repairBrokenPackages(apk)}
if ! $apk add --no-cache claude-code util-linux; then
if ! $apk add --no-cache claude-code util-linux jq; then
if [ -z "$S($apk info -e claude-code)" ] || [ -z "$S($apk info -e util-linux)" ]; then
echo 'and-code: apk failed and the requested packages are not installed' >&2
exit 1
Expand Down Expand Up @@ -196,7 +196,7 @@ object ClaudeCodeInstaller {
$APK policy claude-code 2>&1 || true
""".trimIndent()

private val INSTALL_DIAGNOSTICS_SCRIPT = diagnosticsScript("add -s claude-code util-linux")
private val INSTALL_DIAGNOSTICS_SCRIPT = diagnosticsScript("add -s claude-code util-linux jq")

private val UPDATE_DIAGNOSTICS_SCRIPT = diagnosticsScript("add -s --upgrade claude-code")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,26 @@ import com.yugahashimoto.andcode.core.api.OpenCodePart
import com.yugahashimoto.andcode.core.api.OpenCodeTime
import com.yugahashimoto.andcode.core.api.OpenCodeTodo
import com.yugahashimoto.andcode.core.api.PromptAttachment
import com.yugahashimoto.andcode.runtime.PermissionResponse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import java.io.File
import java.util.UUID

Expand Down Expand Up @@ -56,6 +60,9 @@ class ClaudeCodeRuntime(
private val events = MutableSharedFlow<OpenCodeEvent>(extraBufferCapacity = 256)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val messageStore = ClaudeMessageStore(File(runtimeDirectory, "claude-messages.json"), json)
internal val permissionBridge =
ClaudePermissionBridge(File(runtimeDirectory, ClaudePermissionBridge.HOST_DIR_NAME))
private var bridgeWatchJob: Job? = null

/** One live CLI process, plus what is needed to decide whether it can be reused. */
private class SessionProcess(
Expand Down Expand Up @@ -178,6 +185,64 @@ class ClaudeCodeRuntime(
}
}

/** True when the PermissionRequest hook is present in the guest rootfs. */
fun permissionBridgeReady(): Boolean {
val rootfs = installedRuntimeProvider()?.rootfs ?: return false
return ClaudePermissionHooks.isInstalled(rootfs)
}

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

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 への応答を拒否する実装を推奨する。


fun answerQuestion(
requestId: String,
answers: List<List<String>>,
): Boolean {
val pending = permissionBridge.readPending(requestId) ?: return false
val question = permissionBridge.toQuestionRequest(pending) ?: return false
val mapped = linkedMapOf<String, String>()
question.questions.forEachIndexed { index, prompt ->
val selected = answers.getOrNull(index).orEmpty().joinToString(", ")
if (selected.isNotBlank()) mapped[prompt.question] = selected
}
val questionsJson =
runCatching {
json.parseToJsonElement(pending.toolInputJson).jsonObject["questions"]?.toString() ?: "[]"
}.getOrDefault("[]")
return permissionBridge.answerQuestion(requestId, questionsJson, mapped)
}

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

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 で例外時も継続して監視するよう堅牢化することを推奨する。

when (request.kind) {
ClaudePermissionBridge.Kind.QUESTION -> {
val question = permissionBridge.toQuestionRequest(request)
if (question != null) {
events.tryEmit(OpenCodeEvent.QuestionAsked(question))
} else {
events.tryEmit(
OpenCodeEvent.PermissionAsked(permissionBridge.toPermissionRequest(request)),
)
}
}
else ->
events.tryEmit(
OpenCodeEvent.PermissionAsked(permissionBridge.toPermissionRequest(request)),
)
}
}
delay(250)
}
}
}

fun update() {
val runtime = installedRuntimeProvider() ?: error(messages.runtimeMissing)
cachedVersion = null
Expand Down Expand Up @@ -266,6 +331,14 @@ class ClaudeCodeRuntime(
val runtime = installedRuntimeProvider() ?: error(messages.runtimeMissing)
require(ClaudeCodeInstaller.isInstalledIn(runtime.rootfs)) { "Claude Code is not installed" }
ClaudeCodeInstaller.ensureDnsPreload(runtime.rootfs)
ensureBridgeWatcher()

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

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に見せて選択不可にする等の対策を推奨する。


val stderrLog = File(runtimeDirectory, "logs/claude-stderr.log").also { it.parentFile?.mkdirs() }
val process =
Expand All @@ -274,15 +347,16 @@ class ClaudeCodeRuntime(
runtime = runtime,
workspaceHostDir = File(runtimeDirectory, "workspace").apply { mkdirs() },
workingDirectory = directory,
arguments = processArguments(sessionId, permissionMode, model, effort),
arguments = processArguments(sessionId, effectiveMode, model, effort),
pty = false,
),
).directory(runtimeDirectory)
.redirectError(ProcessBuilder.Redirect.appendTo(stderrLog))
.apply {
environment().clear()
environment().putAll(
ClaudeSandboxLauncher.environment(runtime, File(runtimeDirectory, "proot-tmp").apply { mkdirs() }, githubToken()),
ClaudeSandboxLauncher.environment(runtime, File(runtimeDirectory, "proot-tmp").apply { mkdirs() }, githubToken()) +
mapOf("ANDCODE_ANDROID_SESSION_ID" to sessionId),
)
}
.start()
Expand All @@ -304,7 +378,7 @@ class ClaudeCodeRuntime(
}
}

return SessionProcess(process, readerJob, permissionMode, directory, model, effort)
return SessionProcess(process, readerJob, effectiveMode, directory, model, effort)
.also { sessions[sessionId] = it }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.yugahashimoto.andcode.core.api.PromptRequest
import com.yugahashimoto.andcode.runtime.BackendKind
import com.yugahashimoto.andcode.runtime.LocalAgent
import com.yugahashimoto.andcode.runtime.PermissionResponse
import com.yugahashimoto.andcode.runtime.RuntimeCapabilities
import com.yugahashimoto.andcode.runtime.RuntimeState
import com.yugahashimoto.andcode.runtime.RuntimeTarget
import com.yugahashimoto.andcode.runtime.RuntimeType
Expand Down Expand Up @@ -63,6 +64,17 @@ class ClaudeCodeTarget(
override val kind = BackendKind.LOCAL
override val type = RuntimeType.LOCAL

override val capabilities: RuntimeCapabilities
get() {
val bridge = runtime.permissionBridgeReady()
return RuntimeCapabilities(
permissions = bridge,
questions = bridge,
toolEvents = true,
resume = true,
)
}

private val mutableState = MutableStateFlow<RuntimeState>(RuntimeState.Disconnected)
override val state: StateFlow<RuntimeState> = mutableState.asStateFlow()

Expand Down Expand Up @@ -303,27 +315,40 @@ class ClaudeCodeTarget(
}

/**
* Permission responses are not part of this runtime's contract.
* Answers a PermissionRequest that the guest hook parked on the file bridge.
*
* Streaming-JSON mode has no channel for answering an individual tool prompt, so permissions are
* decided per session through [ClaudePermissionMode] instead. Reporting false keeps the chat
* layer from believing a prompt was answered.
* Returns false when the bridge has no matching request (already timed out or unknown id).
*/
override suspend fun respondToPermission(
sessionId: String,
permissionId: String,
response: PermissionResponse,
remember: Boolean,
): Boolean = false
): Boolean = withContext(Dispatchers.IO) { runtime.respondToPermission(permissionId, response, remember) }

override suspend fun answerQuestion(
requestId: String,
answers: List<List<String>>,
directory: String?,
): Boolean = false
): Boolean = withContext(Dispatchers.IO) { runtime.answerQuestion(requestId, answers) }

override fun events(): Flow<OpenCodeEvent> = runtime.events()

/**
* Working-tree diff for the session's workspace.
*
* Claude Code has no server-side sessionDiff; the workspace git diff is the equivalent surface
* OpenCode exposes for local review.
*/
override suspend fun sessionDiff(
sessionId: String,
directory: String?,
messageId: String?,
): List<OpenCodeFileChange> {
val dir = directory ?: records[sessionId]?.session?.directory ?: WORKSPACE_ROOT
return vcsDiff(dir, mode = "unified", context = 3)
}

// OpenCode answers these over HTTP. Claude Code has no server, but /workspace is a real
// directory on the device, so they are read from disk instead of throwing "unsupported".
override suspend fun listFiles(
Expand Down
Loading
Loading