From 56cd93095047304f1d5f1c84aeba0b0fe228ea3f Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Tue, 8 Sep 2026 17:26:21 +0200 Subject: [PATCH 1/2] tools: add github_pr_create, lex-code's first approval-gated tool Every VCS tool in src/tools/vcs/ wraps lex-vcs (lex branch/lex merge), the structural, content-addressed system built into the lex CLI -- none of them touch git or GitHub. Shipping a change from a lex-code run has always meant a human (or Claude, driving `gh` by hand outside this codebase) taking the current branch the rest of the way. Adds github_pr_create: shells to `gh pr create --title --body [--base]` on the current (already-pushed) branch. Deliberately does not push the branch itself -- that stays an ordinary bash/git action any mode with those tools already has. This is also the first tool in the codebase to carry an approval_scope (t.with_approval, lex-llm#41 / lex-lang#737's std.approval effect) -- `grep -rln "approval_scope" src/tools/` returned nothing before this. Opening a PR is visible to everyone with repo access and cannot be un-opened the way a local edit can be un-written, so it is exactly the class of action every other agent gates behind a human; lex-code had the plumbing since #41 and nothing ever used it. Wired into build (all_tools_for_mode, unrestricted) and refactor (refactor_permission's allowlist) -- not into the local-model minimal/ dynamic toolsets, matching the existing convention of keeping local models' visible surface small. Found and fixed a real gap in lex-lang while validating this: `lex run`'s CLI never wired StdinApprovalSink, so every approval request was unconditionally refused regardless of what stdin contained -- fixed upstream (lex-lang#807). Verified end to end against the real dispatch path here after that fix: denying returns "denied by operator"; approving genuinely proceeds to gh pr create, which then fails for the expected unrelated reason (head branch same as base) -- confirming the tool only ever runs after a real approval. Co-Authored-By: Claude Sonnet 5 --- src/permissions/rules.lex | 2 +- src/tools/github_pr_create.lex | 77 ++++++++++++++++++++++++++++++++++ src/tools/index.lex | 4 +- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/tools/github_pr_create.lex diff --git a/src/permissions/rules.lex b/src/permissions/rules.lex index d296875..33b9657 100644 --- a/src/permissions/rules.lex +++ b/src/permissions/rules.lex @@ -80,7 +80,7 @@ fn spec_permission() -> sp.Spec { } fn refactor_permission() -> sp.Spec { - allow_tools("refactor_tools", list.concat(["read", "write", "edit", "grep", "glob", "bash", "remember", "lex_check", "os_check", "lex_audit", "sigid_lookup", "effects_of", "lex_store_merge", "propagate_effect"], list.concat(vcs_read_names(), vcs_write_names()))) + allow_tools("refactor_tools", list.concat(["read", "write", "edit", "grep", "glob", "bash", "remember", "lex_check", "os_check", "lex_audit", "sigid_lookup", "effects_of", "lex_store_merge", "propagate_effect", "github_pr_create"], list.concat(vcs_read_names(), vcs_write_names()))) } fn test_permission() -> sp.Spec { diff --git a/src/tools/github_pr_create.lex b/src/tools/github_pr_create.lex new file mode 100644 index 0000000..da44aff --- /dev/null +++ b/src/tools/github_pr_create.lex @@ -0,0 +1,77 @@ +# lex-code — open a real GitHub pull request +# +# Every VCS tool in src/tools/vcs/ wraps `lex branch`/`lex merge` — lex-vcs, +# the structural, content-addressed system built into the `lex` CLI itself. +# None of them touch git or GitHub. Shipping a change has always meant a +# human (or Claude, driving `gh` by hand outside this codebase) taking the +# current branch the rest of the way: push, open the PR, wait for CI, merge. +# lex-code itself could build, test and verify a change end to end and then +# had no tool to hand it off with. +# +# This is deliberately the FIRST tool in the codebase to carry an +# approval_scope (`t.with_approval`, lex-llm#41 / lex-lang#737's std.approval +# effect) — plumbing that has existed since #41 landed but nothing here ever +# used: `grep -rln "approval_scope" src/tools/` returned nothing before this +# file. Opening a pull request is visible to everyone with access to the +# repo and cannot be un-opened the way a local edit can be un-written, so it +# is exactly the class of action every other "cutting edge" agent gates +# behind a human — this is lex-code's first one. `bin/lex-code` already +# passes `approval` in its default --allow-effects and never restricts +# --allow-approval, so this blocks on a real stdin prompt (lex-lang's +# StdinApprovalSink) the first time any mode actually calls it — not a +# no-op, and not silently denied by an unconfigured sink. +# +# Shells out to `gh pr create` rather than the GitHub REST API directly: +# every other tool in this codebase that needs a real external CLI (git, +# lex, docker) does the same, and `gh` already owns auth, remote detection +# and the "is this branch pushed" error message this tool would otherwise +# have to reproduce. Pushing the branch itself is deliberately NOT this +# tool's job — that is an ordinary `bash`/`git` action any mode with those +# tools already has, and folding it in here would hide a git push behind +# what looks like a PR-only approval prompt. + +import "std.process" as proc + +import "std.list" as list + +import "lex-llm/tool" as t + +import "lex-schema/json_value" as jv + +import "lex-schema/error" as e + +import "lex-schema/schema" as s + +import "./util" as util + +fn params() -> s.ModelSchema { + { title: "GithubPrCreateArgs", description: "Open a pull request on GitHub for the current (already-pushed) branch.", fields: [s.required_str("title", []), s.required_str("body", []), s.optional(s.required_str("base", []))] } +} + +fn execute(args :: jv.Json) -> [net, io, proc] Result[jv.Json, e.Errors] { + match util.field_str(args, "title") { + None => Err(e.single("", "missing_field", "title is required")), + Some(title) => match util.field_str(args, "body") { + None => Err(e.single("", "missing_field", "body is required")), + Some(body) => { + let base_args := match util.field_str(args, "base") { + None => [], + Some(b) => ["--base", b], + } + let cmd := list.concat(["pr", "create", "--title", title, "--body", body], base_args) + match proc.run("gh", cmd) { + Err(msg) => Err(e.single("", "proc_error", msg)), + Ok(out) => match util.cli_result(out) { + Err(detail) => Err(e.single("", "gh_pr_create_failed", detail)), + Ok(body_out) => Ok(JStr(body_out)), + }, + } + }, + }, + } +} + +fn tool() -> t.Tool { + t.with_approval(t.define("github_pr_create", "Open a pull request on GitHub for the CURRENT branch, which must already be pushed to the remote. Requires operator approval before it runs.", params(), execute), "github_pr_create") +} + diff --git a/src/tools/index.lex b/src/tools/index.lex index 5f13a96..0996394 100644 --- a/src/tools/index.lex +++ b/src/tools/index.lex @@ -46,6 +46,8 @@ import "./propagate_effect" as propagate_tool import "./remember" as remember_tool +import "./github_pr_create" as github_pr_tool + import "./lex_store_merge" as store_merge_tool import "./vcs/ast_diff" as vcs_ast_diff_tool @@ -122,7 +124,7 @@ fn vcs_tools() -> List[t.Tool] { # calling it — it compares a file's effects against that mode's grant — # so the toolset has to be built per mode rather than shared. fn all_tools_for_mode(mode :: Str) -> List[t.Tool] { - list.concat([read_tool.tool(), write_tool.tool(), edit_tool.tool(), grep_tool.tool(), glob_tool.tool(), bash_tool.tool(), todo_tool.tool(), remember_tool.tool(), check_tool.tool(), os_check_tool.tool_for_mode(mode), audit_tool.tool(), semantic_search_tool.tool(), run_tool.tool(), test_tool.tool(), spec_check_tool.tool(), spec_smt_tool.tool(), sigid_tool.tool(), attest_tool.tool(), effects_tool.tool(), store_merge_tool.tool(), propagate_tool.tool(), guidelines_tool.tool(), bar_check_tool.tool()], vcs_tools()) + list.concat([read_tool.tool(), write_tool.tool(), edit_tool.tool(), grep_tool.tool(), glob_tool.tool(), bash_tool.tool(), todo_tool.tool(), remember_tool.tool(), check_tool.tool(), os_check_tool.tool_for_mode(mode), audit_tool.tool(), semantic_search_tool.tool(), run_tool.tool(), test_tool.tool(), spec_check_tool.tool(), spec_smt_tool.tool(), sigid_tool.tool(), attest_tool.tool(), effects_tool.tool(), store_merge_tool.tool(), propagate_tool.tool(), guidelines_tool.tool(), bar_check_tool.tool(), github_pr_tool.tool()], vcs_tools()) } # The build agent's own toolset: build's grant forbids nothing, so this From 143c1edabf43600d1dcfa99421940120dde1f7d4 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Tue, 8 Sep 2026 17:29:45 +0200 Subject: [PATCH 2/2] smoke: skip github_pr_create, same reason as the other side-effecting tools CI's smoke test requires every tool to either invoke a real command it can safely run unconditionally, or be in the skip list with a reason. github_pr_create shells to a real `gh pr create` and is now also approval-gated -- a smoke run in CI has no interactive stdin to answer that prompt, so it would either hang or (once StdinApprovalSink hits EOF) deny every time, proving nothing either way. Same shape as vcs_branch_create/vcs_merge_start/write/edit/bash: covered by its own CLI-shape check, not run for real here. Co-Authored-By: Claude Sonnet 5 --- src/tools/smoke.lex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/smoke.lex b/src/tools/smoke.lex index 05dc4a6..a88fe31 100644 --- a/src/tools/smoke.lex +++ b/src/tools/smoke.lex @@ -143,10 +143,10 @@ fn render(o :: Outcome) -> Str { # one anybody kept. fn skipped() -> List[Str] examples { - skipped() => ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull"] + skipped() => ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull", "github_pr_create"] } { - ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull"] + ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull", "github_pr_create"] } fn is_skipped(name :: Str) -> Bool