RFC-029: Shell Exec Tool
RFC-029: Shell Exec Tool
Document Information
- RFC ID: RFC-029
- Related PRD: FEAT-029 (Shell Exec Tool)
- Related Architecture: RFC-000 (Overall Architecture)
- Related RFC: RFC-004 (Tool System)
- Created: 2026-03-01
- Last Updated: 2026-03-01
- Status: Draft
- Author: TBD
Overview
Background
OneClaw’s AI agent currently interacts with the device through a limited set of built-in tools (webfetch, browser, schedule_task, etc.) and JS-based tools (file read/write, HTTP, time). However, there is no general-purpose tool for executing arbitrary shell commands on the device. Shell access would enable the agent to perform a wide range of tasks: file system operations, system diagnostics, package management, network testing, text processing, and automation scripting.
Android provides Runtime.getRuntime().exec() which allows apps to spawn shell processes within their sandbox. These processes run with the app’s UID and have the same permissions as the app itself – no root access, bounded by Android’s security model.
Goals
- Implement
ExecTool.ktas a Kotlin built-in tool intool/builtin/ - Execute shell commands via
Runtime.getRuntime().exec()withsh -cwrapping - Capture stdout, stderr, and exit code from the child process
- Enforce configurable timeout with forced process termination
- Support configurable working directory and output truncation
- Register the tool in
ToolModule
Non-Goals
- Interactive shell sessions with persistent state
- Root command execution
- Command allowlist/blocklist filtering
- Streaming output to the UI in real-time
- Per-command environment variable overrides
- Background process management
Technical Design
Architecture Overview
+-----------------------------------------------------------------+
| Chat Layer (RFC-001) |
| SendMessageUseCase |
| | |
| | tool call: exec(command="ls -la /sdcard/") |
| v |
+------------------------------------------------------------------+
| Tool Execution Engine (RFC-004) |
| executeTool(name, params, availableToolIds) |
| | |
| v |
| +--------------------------------------------------------------+ |
| | ToolRegistry | |
| | +-------------------+ | |
| | | exec | Kotlin built-in [NEW] | |
| | | (ExecTool.kt) | | |
| | +--------+----------+ | |
| | | | |
| | v | |
| | +------------------------------------------------------+ | |
| | | ExecTool | | |
| | | 1. Validate parameters | | |
| | | 2. Create ProcessBuilder("sh", "-c", command) | | |
| | | 3. Set working directory | | |
| | | 4. Start process | | |
| | | 5. Read stdout/stderr in coroutines | | |
| | | 6. Wait with timeout | | |
| | | 7. Format and return result | | |
| | +------------------------------------------------------+ | |
| +--------------------------------------------------------------+ |
+-------------------------------------------------------------------+
Core Components
New:
ExecTool– Kotlin built-in tool that executes shell commands and returns output
Modified:
ToolModule– RegisterExecToolas a Kotlin built-in tool
Detailed Design
Directory Structure (New & Changed Files)
app/src/main/
├── kotlin/com/oneclaw/shadow/
│ ├── tool/
│ │ └── builtin/
│ │ ├── ExecTool.kt # NEW
│ │ ├── WebfetchTool.kt # unchanged
│ │ ├── BrowserTool.kt # unchanged
│ │ ├── LoadSkillTool.kt # unchanged
│ │ ├── CreateScheduledTaskTool.kt # unchanged
│ │ └── CreateAgentTool.kt # unchanged
│ └── di/
│ └── ToolModule.kt # MODIFIED
app/src/test/kotlin/com/oneclaw/shadow/
└── tool/
└── builtin/
└── ExecToolTest.kt # NEW
ExecTool
/**
* Located in: tool/builtin/ExecTool.kt
*
* Kotlin built-in tool that executes shell commands on the Android device
* using Runtime.getRuntime().exec(). Captures stdout, stderr, and exit code.
*/
class ExecTool(
private val context: Context
) : Tool {
companion object {
private const val TAG = "ExecTool"
private const val DEFAULT_TIMEOUT_SECONDS = 30
private const val MAX_TIMEOUT_SECONDS = 120
private const val DEFAULT_MAX_LENGTH = 50_000
}
override val definition = ToolDefinition(
name = "exec",
description = "Execute a shell command on the device and return its output",
parametersSchema = ToolParametersSchema(
properties = mapOf(
"command" to ToolParameter(
type = "string",
description = "The shell command to execute"
),
"timeout_seconds" to ToolParameter(
type = "integer",
description = "Maximum execution time in seconds. Default: 30, Max: 120"
),
"working_directory" to ToolParameter(
type = "string",
description = "Working directory for the command. Default: app data directory"
),
"max_length" to ToolParameter(
type = "integer",
description = "Maximum output length in characters. Default: 50000"
)
),
required = listOf("command")
),
requiredPermissions = emptyList(),
timeoutSeconds = MAX_TIMEOUT_SECONDS + 5 // Extra buffer beyond process timeout
)
override suspend fun execute(parameters: Map<String, Any?>): ToolResult {
// 1. Parse and validate parameters
val command = parameters["command"]?.toString()?.trim()
if (command.isNullOrBlank()) {
return ToolResult.error(
"validation_error",
"Parameter 'command' is required and cannot be empty"
)
}
val timeoutSeconds = parseIntParam(parameters["timeout_seconds"])
?.coerceIn(1, MAX_TIMEOUT_SECONDS)
?: DEFAULT_TIMEOUT_SECONDS
val maxLength = parseIntParam(parameters["max_length"])
?.coerceAtLeast(1)
?: DEFAULT_MAX_LENGTH
val workingDir = parameters["working_directory"]?.toString()?.let { path ->
val dir = File(path)
if (!dir.exists() || !dir.isDirectory) {
return ToolResult.error(
"validation_error",
"Working directory does not exist: $path"
)
}
dir
} ?: context.filesDir
// 2. Execute the command
return try {
executeCommand(command, workingDir, timeoutSeconds, maxLength)
} catch (e: SecurityException) {
ToolResult.error("permission_error", "Permission denied: ${e.message}")
} catch (e: IOException) {
ToolResult.error("execution_error", "Failed to start process: ${e.message}")
} catch (e: Exception) {
Log.e(TAG, "Unexpected error executing command", e)
ToolResult.error("execution_error", "Error: ${e.message}")
}
}
private suspend fun executeCommand(
command: String,
workingDir: File,
timeoutSeconds: Int,
maxLength: Int
): ToolResult = withContext(Dispatchers.IO) {
val process = Runtime.getRuntime().exec(
arrayOf("sh", "-c", command),
null, // inherit environment
workingDir
)
try {
// 3. Capture stdout and stderr concurrently
val stdoutDeferred = async {
readStream(process.inputStream, maxLength)
}
val stderrDeferred = async {
readStream(process.errorStream, maxLength)
}
// 4. Wait for process completion with timeout
val completed = process.waitFor(
timeoutSeconds.toLong(),
TimeUnit.SECONDS
)
if (!completed) {
// Timeout: kill the process
process.destroyForcibly()
process.waitFor(5, TimeUnit.SECONDS) // Brief wait for cleanup
val stdout = stdoutDeferred.await()
val stderr = stderrDeferred.await()
return@withContext ToolResult.success(
formatOutput(
exitCode = -1,
stdout = stdout,
stderr = stderr,
timedOut = true,
timeoutSeconds = timeoutSeconds
)
)
}
val exitCode = process.exitValue()
val stdout = stdoutDeferred.await()
val stderr = stderrDeferred.await()
ToolResult.success(
formatOutput(
exitCode = exitCode,
stdout = stdout,
stderr = stderr,
timedOut = false,
timeoutSeconds = timeoutSeconds
)
)
} finally {
process.destroy()
}
}
/**
* Read an InputStream into a String, truncating at maxLength.
*/
private fun readStream(stream: InputStream, maxLength: Int): String {
val reader = BufferedReader(InputStreamReader(stream))
val sb = StringBuilder()
var totalRead = 0
reader.use {
val buffer = CharArray(8192)
while (true) {
val count = reader.read(buffer)
if (count == -1) break
val remaining = maxLength - totalRead
if (remaining <= 0) break
val toAppend = minOf(count, remaining)
sb.append(buffer, 0, toAppend)
totalRead += toAppend
if (totalRead >= maxLength) break
}
}
return sb.toString()
}
/**
* Format the output with exit code, stdout, stderr, and timeout info.
*/
private fun formatOutput(
exitCode: Int,
stdout: String,
stderr: String,
timedOut: Boolean,
timeoutSeconds: Int
): String {
val sb = StringBuilder()
if (timedOut) {
sb.appendLine("[Exit Code: -1 (timeout after ${timeoutSeconds}s)]")
} else {
sb.appendLine("[Exit Code: $exitCode]")
}
if (stdout.isNotEmpty()) {
sb.appendLine()
sb.append(stdout)
if (!stdout.endsWith("\n")) sb.appendLine()
}
if (stderr.isNotEmpty()) {
sb.appendLine()
sb.appendLine("[stderr]")
sb.append(stderr)
if (!stderr.endsWith("\n")) sb.appendLine()
}
if (timedOut) {
if (stderr.isEmpty()) {
sb.appendLine()
sb.appendLine("[stderr]")
}
sb.appendLine("Process killed after ${timeoutSeconds} seconds timeout.")
}
if (stdout.isEmpty() && stderr.isEmpty() && !timedOut) {
sb.appendLine()
sb.appendLine("(no output)")
}
return sb.toString().trimEnd()
}
private fun parseIntParam(value: Any?): Int? {
return when (value) {
is Int -> value
is Long -> value.toInt()
is Double -> value.toInt()
is Number -> value.toInt()
is String -> value.toIntOrNull()
else -> null
}
}
}
ToolModule Changes
// In ToolModule.kt
val toolModule = module {
// ... existing registrations ...
// RFC-029: exec built-in tool
single { ExecTool(androidContext()) }
single {
ToolRegistry().apply {
// ... existing tool registrations ...
try {
register(get<ExecTool>(), ToolSourceInfo.BUILTIN)
} catch (e: Exception) {
Log.e("ToolModule", "Failed to register exec: ${e.message}")
}
// ... rest of initialization ...
}
}
}
Imports Required for ExecTool
import android.content.Context
import android.util.Log
import com.oneclaw.shadow.core.model.ToolDefinition
import com.oneclaw.shadow.core.model.ToolParameter
import com.oneclaw.shadow.core.model.ToolParametersSchema
import com.oneclaw.shadow.core.model.ToolResult
import com.oneclaw.shadow.tool.engine.Tool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
Implementation Plan
Phase 1: ExecTool Core Implementation
- Create
ExecTool.ktintool/builtin/ - Implement command execution via
Runtime.getRuntime().exec() - Implement stdout/stderr capture with concurrent readers
- Implement timeout enforcement with
Process.waitFor(timeout, unit) - Implement output formatting with exit code
Phase 2: Integration
- Update
ToolModule.ktto registerExecTool - Add
ExecToolimport and Koin single registration - Add registration in the
ToolRegistry.applyblock
Phase 3: Testing
- Create
ExecToolTest.ktwith unit tests - Run Layer 1A tests (
./gradlew test) - Run Layer 1B tests if emulator available
- Manual testing with various shell commands on device
Data Model
No data model changes. ExecTool implements the existing Tool interface.
API Design
Tool Interface
Tool Name: exec
Parameters:
- command: string (required) -- The shell command to execute
- timeout_seconds: integer (optional, default: 30, max: 120) -- Timeout
- working_directory: string (optional, default: app data dir) -- CWD
- max_length: integer (optional, default: 50000) -- Max output chars
Returns on success:
Formatted text with exit code, stdout, stderr
Returns on error:
ToolResult.error with descriptive message
Output Format Examples
Successful command:
[Exit Code: 0]
total 48
drwxr-xr-x 2 u0_a123 u0_a123 4096 2026-03-01 10:00 .
-rw-r--r-- 1 u0_a123 u0_a123 1234 2026-03-01 09:30 file.txt
Command with error:
[Exit Code: 1]
[stderr]
ls: cannot access '/nonexistent': No such file or directory
Command with both stdout and stderr:
[Exit Code: 0]
Processing file1.txt
Processing file2.txt
[stderr]
Warning: file3.txt skipped (empty)
Timed-out command:
[Exit Code: -1 (timeout after 30s)]
partial output here...
[stderr]
Process killed after 30 seconds timeout.
Process Lifecycle
execute() called
|
v
Validate parameters (command, timeout, working_directory)
|
v
Runtime.getRuntime().exec(["sh", "-c", command], null, workingDir)
|
+-- Process spawned
|
+-- async: read stdout into StringBuilder
+-- async: read stderr into StringBuilder
|
v
process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
+-- true (completed) -----> Get exitValue(), await stdout/stderr
| |
| v
| formatOutput(exitCode, stdout, stderr)
| |
| v
| ToolResult.success(formatted)
|
+-- false (timeout) -----> process.destroyForcibly()
|
v
await stdout/stderr (partial)
|
v
formatOutput(-1, stdout, stderr, timedOut=true)
|
v
ToolResult.success(formatted)
Error Handling
| Error | Cause | Error Type | Handling |
|---|---|---|---|
| Empty command | Blank or null command param |
validation_error |
Return immediately with error message |
| Invalid working directory | Path does not exist or is not a directory | validation_error |
Return immediately with error message |
| Process creation failure | System resource limits, invalid command | execution_error |
Catch IOException, return error |
| Permission denied | Command requires permissions the app doesn’t have | permission_error |
Catch SecurityException, return error |
| Timeout | Command exceeds timeout_seconds |
N/A (success) | Kill process, return partial output with timeout indicator |
| I/O error | Stream read failure during output capture | execution_error |
Catch IOException, return error |
| Unexpected error | Any other exception | execution_error |
Log and return generic error |
Note: Timeout is not treated as an error – the tool returns success with the timeout indicator in the output, because partial output may still be useful to the AI model.
Security Considerations
-
Android App Sandbox: Processes spawned via
Runtime.exec()run with the app’s UID. They cannot access other apps’ data, system files, or perform privileged operations unless the device is rooted. This is enforced by the Linux kernel, not by the tool. -
No Root Escalation: The tool does not use
suor attempt to escalate privileges. If the user’s device is rooted andsuis in PATH, the AI model could theoretically callsu -c "...", but this is constrained by the device’s root management app (e.g., Magisk) which prompts the user for approval. - No Command Filtering (V1): The tool does not blocklist or allowlist commands. The rationale:
- The Android sandbox already limits what the process can do
- Command filtering is easily bypassed (encoding, indirection, scripts)
- The AI model already has file read/write tools with similar access
- Users who install this agent app accept the risk of AI-driven device interaction
-
Resource Limits: Timeout enforcement prevents runaway processes. Output truncation prevents memory exhaustion. The process is always destroyed in the
finallyblock. - No Network Exfiltration Risk: The tool only returns output to the AI model, which is already in the app’s process. No additional network surface is exposed.
Performance
| Operation | Expected Time | Notes |
|---|---|---|
| Process creation | ~50-100ms | Fork + exec overhead |
| stdout/stderr capture | Depends on command | Buffered I/O, concurrent readers |
| Timeout enforcement | Accurate to ~1s | Uses Process.waitFor(long, TimeUnit) |
| Process cleanup | < 100ms | destroy() + destroyForcibly() |
Memory usage:
- stdout and stderr buffered in StringBuilder (capped at
max_length) - Process handle and streams are closed in
finallyblock - No persistent state between calls
Testing Strategy
Unit Tests
ExecToolTest.kt:
testExecute_simpleCommand–echo helloreturns “hello” with exit code 0testExecute_commandWithExitCode–exit 42returns exit code 42testExecute_commandWithStderr– Command producing stderr outputtestExecute_commandWithPipes–echo hello | tr a-z A-Zreturns “HELLO”testExecute_timeout–sleep 60with timeout_seconds=2 triggers timeouttestExecute_workingDirectory–pwdwith custom working_directorytestExecute_emptyCommand– Blank command returns validation errortestExecute_invalidWorkingDir– Non-existent directory returns validation errortestExecute_maxLength– Large output is truncatedtestExecute_noOutput– Command with no stdout/stderrtestExecute_timeoutClamped– timeout_seconds > 120 is clampedtestDefinition– Tool definition has correct name and parameters
Integration Tests (Layer 1B)
- Execute
lson the device and verify output contains expected directories - Execute
getprop ro.build.version.sdkand verify it returns a number - Execute a command that accesses
/sdcard/(if storage permission granted)
Manual Testing (Layer 2)
- Run
pm list packagesand verify app package listing - Run
cat /proc/cpuinfoand verify hardware info output - Run a command chain with pipes and redirects
- Run a command that exceeds timeout and verify clean termination
- Run a command in a specific working directory
Alternatives Considered
1. Use ProcessBuilder Instead of Runtime.exec()
Approach: Use ProcessBuilder which offers more control (redirect stderr to stdout, environment configuration).
Decision: Use Runtime.getRuntime().exec() as the user specified. Internally, Runtime.exec() creates a ProcessBuilder anyway. The implementation above uses Runtime.exec() directly. In future iterations, we could switch to ProcessBuilder if we need stderr merging or per-command environment variables.
2. Use a Persistent Shell Session
Approach: Maintain a long-running sh process and pipe commands to its stdin.
Rejected for V1: Adds complexity (shell state management, prompt detection, output boundary markers). Each command being an independent process is simpler and more predictable. Can be added as a future enhancement.
3. Command Blocklist
Approach: Maintain a list of dangerous commands (rm -rf, reboot, etc.) and reject them. Rejected for V1: Easily bypassed (command encoding, scripts, aliases). The Android sandbox already prevents truly dangerous operations. Users of an AI agent app accept the risk. Can be added as an optional safety layer later.
Dependencies
External Dependencies
None. Uses only Android platform APIs:
java.lang.Runtimejava.lang.Processjava.io.BufferedReader/java.io.InputStreamReaderjava.util.concurrent.TimeUnit
Internal Dependencies
Toolinterface fromtool/engine/ToolResult,ToolDefinition,ToolParametersSchema,ToolParameterfromcore/model/Contextfrom Android (for default working directory)
Future Extensions
- Persistent shell session: Maintain a running
shprocess for stateful command sequences (cd, environment variables) - Command approval: Optional UI confirmation dialog before executing commands
- Command blocklist/allowlist: Configurable safety filter for commands
- Environment variables: Per-command environment variable overrides
- Streaming output: Real-time stdout/stderr display in the chat UI
- Multiple shell support: Allow selecting
bash,zsh, or other shells if available - Background processes: Support for long-running processes that report back periodically
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-03-01 | 0.1 | Initial version | - |