Skip to content

Commit 97c0701

Browse files
exec tool: witnessed shell execution
Add exec MCP tool — agents execute shell commands through gall instead of raw bash. Every execution witnessed as @exec fragment in session trace. Fragment data carries command + output hash. Works with or without active session. 4 new tests (44 total). TDD arc: red then green.
1 parent 443f458 commit 97c0701

5 files changed

Lines changed: 137 additions & 3 deletions

File tree

src/gall/daemon.gleam

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ fn git_blame_ffi(dir: String, path: String) -> String
7272
@external(erlang, "gall_ffi", "git_show_file")
7373
fn git_show_file_ffi(dir: String, ref: String, path: String) -> String
7474

75+
@external(erlang, "gall_ffi", "exec")
76+
fn exec_ffi(dir: String, command: String) -> String
77+
7578
@external(erlang, "gall_ffi", "list_gestalt_sessions")
7679
fn list_gestalt_sessions_ffi(gall_dir: String) -> String
7780

@@ -383,6 +386,29 @@ fn handle_tool_call(
383386
}
384387
}
385388

389+
// Exec — shell command execution, witnessed as @exec
390+
"exec" -> {
391+
case json.get_string(args, "command") {
392+
Error(_) -> #(
393+
state,
394+
Some(make_response(
395+
id,
396+
content_text(err_json("exec requires command")),
397+
)),
398+
None,
399+
)
400+
Ok(command) -> {
401+
let out = exec_ffi(state.work_dir, command)
402+
let #(next_state, exec_frag) = record_exec(state, command, out)
403+
#(
404+
next_state,
405+
Some(make_response(id, content_text(json_string(out)))),
406+
exec_frag,
407+
)
408+
}
409+
}
410+
}
411+
386412
_ -> #(
387413
state,
388414
Some(make_response(
@@ -592,6 +618,43 @@ fn record_read(
592618
}
593619
}
594620

621+
// ---------------------------------------------------------------------------
622+
// @exec annotation
623+
// ---------------------------------------------------------------------------
624+
625+
/// When the agent runs a shell command through gall, record it as an @exec
626+
/// Fragment. The fragment data carries the command and a hash of the output
627+
/// (not the full output, which could be huge).
628+
fn record_exec(
629+
state: State,
630+
command: String,
631+
output: String,
632+
) -> #(State, Option(fragmentation.Fragment)) {
633+
case state.sess {
634+
Idle -> #(state, None)
635+
Active(session: s, ..) as active -> {
636+
let ts = int.to_string(now())
637+
let author = case session.config(s) {
638+
session.SessionConfig(author: a, ..) -> a
639+
}
640+
let fragmentation.Sha(self: output_hash) = fragmentation.hash(output)
641+
let w =
642+
fragmentation.witnessed(
643+
fragmentation.Author(author),
644+
fragmentation.Committer("gall"),
645+
fragmentation.Timestamp(ts),
646+
fragmentation.Message("@exec"),
647+
)
648+
let data = "command: " <> command <> "\noutput_sha: " <> output_hash
649+
let r = fragmentation.ref(fragmentation.hash(ts <> data), "exec")
650+
let frag = fragmentation.shard(r, w, data)
651+
let #(s2, _) = session.act(s, "@exec", "command: " <> command)
652+
let next_sess = Active(..active, session: s2)
653+
#(State(..state, sess: next_sess), Some(frag))
654+
}
655+
}
656+
}
657+
595658
fn path_visibility(path: String) -> String {
596659
case string.starts_with(path, "visibility/private/") {
597660
True -> ":private"

src/gall/tools.gleam

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616

1717
/// All tool names, in the order they appear in the daemon's tools/list.
1818
pub fn tool_names() -> List(String) {
19-
list_append(ado_tool_names(), git_tool_names())
19+
list_append(
20+
ado_tool_names(),
21+
list_append(git_tool_names(), exec_tool_names()),
22+
)
2023
}
2124

2225
/// ADO witnessing tool names.
@@ -29,6 +32,11 @@ pub fn git_tool_names() -> List(String) {
2932
["git_status", "git_diff", "git_log", "git_blame", "git_show_file"]
3033
}
3134

35+
/// Exec tool names (daemon-only).
36+
pub fn exec_tool_names() -> List(String) {
37+
["exec"]
38+
}
39+
3240
// ---------------------------------------------------------------------------
3341
// Composed tool lists (for tools/list responses)
3442
// ---------------------------------------------------------------------------
@@ -53,6 +61,8 @@ pub fn daemon_tools_json() -> String {
5361
<> git_blame_schema()
5462
<> ","
5563
<> git_show_file_schema()
64+
<> ","
65+
<> exec_schema()
5666
<> "]}"
5767
}
5868

@@ -183,6 +193,20 @@ pub fn git_show_file_schema() -> String {
183193
<> "\"required\":[\"path\"]}}"
184194
}
185195

196+
// ---------------------------------------------------------------------------
197+
// Exec tool schema (daemon-only)
198+
// ---------------------------------------------------------------------------
199+
200+
pub fn exec_schema() -> String {
201+
"{\"name\":\"exec\","
202+
<> "\"description\":\"Execute a shell command in the project directory. Witnessed as @exec in the session trace.\","
203+
<> "\"inputSchema\":{\"type\":\"object\","
204+
<> "\"properties\":{"
205+
<> "\"command\":{\"type\":\"string\","
206+
<> "\"description\":\"Shell command to execute.\"}},"
207+
<> "\"required\":[\"command\"]}}"
208+
}
209+
186210
// ---------------------------------------------------------------------------
187211
// Helpers
188212
// ---------------------------------------------------------------------------

src/gall_ffi.erl

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
git_show_file/3,
2828
%% Gestalt resources
2929
list_gestalt_sessions/1,
30-
read_gestalt_session/2
30+
read_gestalt_session/2,
31+
%% Shell exec
32+
exec/2
3133
]).
3234

3335
%% ---------------------------------------------------------------------------
@@ -471,3 +473,12 @@ send_patch(RepoDir, Remote) ->
471473
++ " push " ++ RemoteStr ++ " HEAD 2>&1")
472474
end,
473475
ok.
476+
477+
%% ---------------------------------------------------------------------------
478+
%% Shell exec
479+
%% ---------------------------------------------------------------------------
480+
481+
%% Execute a shell command in Dir, capturing stdout+stderr.
482+
exec(Dir, Command) ->
483+
Cmd = "cd " ++ binary_to_list(Dir) ++ " && " ++ binary_to_list(Command) ++ " 2>&1",
484+
unicode:characters_to_binary(os:cmd(Cmd)).

test/gall_exec_test.gleam

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import gall/tools
2+
import gleam/list
3+
import gleam/string
4+
import gleeunit/should
5+
6+
// ---------------------------------------------------------------------------
7+
// exec schema
8+
// ---------------------------------------------------------------------------
9+
10+
pub fn exec_schema_has_name_test() {
11+
let schema = tools.exec_schema()
12+
schema |> string.contains("\"name\":\"exec\"") |> should.be_true()
13+
}
14+
15+
pub fn exec_schema_has_required_command_test() {
16+
let schema = tools.exec_schema()
17+
schema |> string.contains("\"required\":[\"command\"]") |> should.be_true()
18+
}
19+
20+
// ---------------------------------------------------------------------------
21+
// tool names include exec
22+
// ---------------------------------------------------------------------------
23+
24+
pub fn tool_names_include_exec_test() {
25+
let names = tools.tool_names()
26+
names |> list.contains("exec") |> should.be_true()
27+
}
28+
29+
// ---------------------------------------------------------------------------
30+
// daemon_tools_json includes exec
31+
// ---------------------------------------------------------------------------
32+
33+
pub fn daemon_tools_json_contains_exec_test() {
34+
let json = tools.daemon_tools_json()
35+
json |> string.contains("\"exec\"") |> should.be_true()
36+
}

test/gall_tools_test.gleam

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub fn tool_names_include_ado_tools_test() {
1111
names
1212
|> should.equal([
1313
"observe", "decide", "act", "commit", "git_status", "git_diff", "git_log",
14-
"git_blame", "git_show_file",
14+
"git_blame", "git_show_file", "exec",
1515
])
1616
}
1717

0 commit comments

Comments
 (0)