Skip to content

scripting: add Starlark scripting system for LiT automation#1220

Draft
sputn1ck wants to merge 3 commits intomasterfrom
scripting-system
Draft

scripting: add Starlark scripting system for LiT automation#1220
sputn1ck wants to merge 3 commits intomasterfrom
scripting-system

Conversation

@sputn1ck
Copy link
Member

@sputn1ck sputn1ck commented Jan 30, 2026

Summary

This PR introduces a Starlark scripting system for Lightning Terminal that enables custom automation with access to LND RPCs. Scripts use native LND macaroons for permission enforcement.

Key Features

  • Starlark execution engine with sandboxed resource limits
  • Standard builtins: print, log, sleep, now, json_encode/decode
  • KV store builtins: kv_get, kv_put, kv_delete, kv_list
  • LND RPC bindings: get_info, list_channels, channel_balance, wallet_balance, add_invoice, send_payment, and many more
  • Script CRUD operations with automatic macaroon baking
  • Execution history and running script tracking

Example Script

def main():
    info = lnd.get_info()
    channels = lnd.list_channels()
    
    return {
        "alias": info["alias"],
        "num_channels": len(channels["channels"]),
        "block_height": info["block_height"],
    }

Missing Features (TODO)

The following features are not yet implemented:

  1. SQL Store - Currently using in-memory store (InMemoryStore). Need to implement SQLStore with the migrations in db/sqlc/migrations/000006_scripts.up.sql

  2. Generic RPC Caller - The RPCCaller interface is defined but not implemented. This would allow scripts to call arbitrary RPCs dynamically

  3. HTTP Builtins - http_get with URL allowlist for external API calls is not yet implemented

  4. Subscription Builtins - LND event subscriptions (subscribe_invoices, subscribe_channel_events, etc.) for long-running daemon scripts are not implemented

  5. CLI Commands - litcli scripts commands need to be added to cmd/litcli/

  6. Proto Generation - The litrpc/lit-scripts.proto file exists but proto generation needs to be integrated into the build

  7. Loop/Pool/Taproot-Assets Bindings - Only LND bindings are implemented; other subdaemon bindings need to be added

Understanding the System

The integration tests in itest/litd_scripts_test.go are the best place to understand how the system works. They cover:

  • Script CRUD operations
  • Script validation
  • Script execution with arguments
  • KV store operations (both RPC and builtin)
  • LND RPC access from scripts
  • Standard and KV builtins

Test Plan

  • All 7 scripts integration tests pass
  • Manual testing with real scripts
  • Add unit tests for individual components

@gemini-code-assist
Copy link

Summary of Changes

Hello @sputn1ck, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request integrates a powerful and flexible scripting engine into Lightning Terminal, allowing for advanced automation and custom logic. By leveraging Starlark, users can now programmatically interact with their LND node and manage persistent data, opening up new possibilities for operational efficiency and tailored workflows within the Lightning ecosystem. The changes lay the groundwork for a highly extensible platform, providing a secure and controlled environment for executing user-defined code.

Highlights

  • Starlark Scripting System: Introduces a new Starlark-based scripting system for Lightning Terminal (LiT), enabling users to write custom automation scripts that interact with LND RPCs and other LiT functionalities.
  • LND RPC Bindings & Macaroon Permissions: Scripts can access a wide range of LND RPCs (e.g., get_info, list_channels, add_invoice, send_payment) with permissions enforced by native LND macaroons, ensuring secure and granular control over script capabilities.
  • Persistent Key-Value Store: Includes a built-in key-value (KV) store (kv_get, kv_put, kv_delete, kv_list) for scripts to store and retrieve persistent data, allowing for stateful automation.
  • Resource Sandboxing & Execution Management: Scripts run within a sandboxed environment with configurable resource limits (memory, timeout) and can be managed via new litcli scripts commands for creation, updating, deletion, starting, stopping, and viewing execution history.
  • Comprehensive Integration Tests: Adds extensive integration tests covering script CRUD operations, validation, execution with arguments, KV store interactions, LND RPC access, and standard built-in functions to ensure robustness and correctness of the new system.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

This commit introduces a comprehensive Starlark scripting system that
enables custom automation with access to all subdaemon RPCs (lnd, loop,
pool, faraday, taproot-assets). Scripts use native LND macaroons for
permission enforcement.

Key features:
- Starlark execution engine with sandboxed resource limits
- Standard builtins: print, log, sleep, now, json_encode/decode
- HTTP GET requests with URL allowlisting
- Persistent KV store with bucket-based permissions
- LND event subscriptions for long-running daemon scripts
- Script CRUD operations with macaroon baking
- Execution history and running script tracking
- Complete CLI commands (litcli scripts ...)

Database schema:
- scripts: Store script definitions with macaroon permissions
- script_executions: Audit trail for script runs
- script_kv_store: Persistent key-value storage for scripts
- running_scripts: Track currently running scripts

Security model:
- Each script has an LND macaroon baked with specific permissions
- RPC calls from scripts include this macaroon in the header
- LND/subdaemons validate permissions natively
- URL and bucket allowlists validated at runtime
This commit adds:

1. LND RPC bindings (scripting/builtins_lnd.go):
   - get_info, get_node_info
   - list_channels, channel_balance, pending_channels, closed_channels
   - update_channel_policy
   - wallet_balance, list_unspent, new_address, send_coins
   - add_invoice, lookup_invoice, list_invoices, decode_pay_req
   - send_payment, list_payments
   - list_peers, connect_peer, disconnect_peer
   - forwarding_history, estimate_fee

2. Integration tests (itest/litd_scripts_test.go):
   - testScriptBasicCRUD: Tests create, read, update, delete operations
   - testScriptValidation: Tests script syntax validation
   - testScriptExecution: Tests script execution with arguments
   - testScriptKVStore: Tests KV store RPC operations
   - testScriptWithLNDAccess: Tests scripts calling LND RPCs
   - testScriptBuiltins: Tests standard builtins (now, json_encode, etc.)
   - testScriptWithKVBuiltins: Tests KV builtins from scripts

3. Test registration in litd_test_list_on_test.go
This commit fixes LND RPC authentication by switching from raw gRPC
clients to lndclient wrappers that provide proper macaroon authentication.

Changes:
- Update LNDClients to use lndclient.LightningClient and
  lndclient.RouterClient instead of raw gRPC clients
- Add getRawLightningClient() and getRawRouterClient() helpers that use
  RawClientWithMacAuth() for authenticated raw client access
- Update all 21 LND builtin methods to use the new pattern
- Add InMemoryStore implementation for testing
- Wire up LND clients in terminal.go after LND connects
- Fix integration test function signatures

All 7 scripts integration tests now pass:
- scripts_basic_crud
- scripts_validation
- scripts_execution
- scripts_kv_store
- scripts_lnd_access
- scripts_builtins
- scripts_kv_builtins
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant new feature: a Starlark scripting system for LiT automation. The scope of the changes is large, adding new RPCs, CLI commands, database migrations, and a whole new scripting package. The code is generally well-structured, and the inclusion of integration tests is commendable.

I have identified several issues, including two critical ones: a security vulnerability related to macaroon handling that could grant scripts excessive permissions, and a bug in an RPC call implementation that would cause it to fail. I've also found other bugs related to data serialization in the KV store and areas for improvement in code clarity, correctness, and robustness. My review provides specific suggestions to address these points.

Comment on lines +85 to +88
func (lb *lndBuiltins) getRawLightningClient() (context.Context, lnrpc.LightningClient) {
parentCtx := lb.engine.sandbox.Context()
ctx, _, client := lb.clients.Lightning.RawClientWithMacAuth(parentCtx)
return ctx, client

Choose a reason for hiding this comment

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

critical

This is a critical security vulnerability. The getRawLightningClient function calls lb.clients.Lightning.RawClientWithMacAuth(parentCtx), which uses the main LiT daemon's macaroon, not the script-specific macaroon. This grants the script far more permissions than intended. The same issue exists in getRawRouterClient.

You must use the script's own macaroon, stored in lb.macaroon, for every RPC call. You can achieve this by creating a new context with the script's macaroon in the metadata and using the raw gRPC client.

You will need to add "google.golang.org/grpc/metadata" to the imports.

Suggested change
func (lb *lndBuiltins) getRawLightningClient() (context.Context, lnrpc.LightningClient) {
parentCtx := lb.engine.sandbox.Context()
ctx, _, client := lb.clients.Lightning.RawClientWithMacAuth(parentCtx)
return ctx, client
func (lb *lndBuiltins) getRawLightningClient() (context.Context, lnrpc.LightningClient) {
parentCtx := lb.engine.sandbox.Context()
ctx := metadata.AppendToOutgoingContext(parentCtx, "macaroon", lb.macaroon)
return ctx, lb.clients.Lightning.Raw()

Comment on lines +93 to +96
func (lb *lndBuiltins) getRawRouterClient() (context.Context, routerrpc.RouterClient) {
parentCtx := lb.engine.sandbox.Context()
ctx, _, client := lb.clients.Router.RawClientWithMacAuth(parentCtx)
return ctx, client

Choose a reason for hiding this comment

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

critical

Similar to getRawLightningClient, this function is using the main LiT macaroon instead of the script's restricted macaroon. This is a critical security vulnerability.

Suggested change
func (lb *lndBuiltins) getRawRouterClient() (context.Context, routerrpc.RouterClient) {
parentCtx := lb.engine.sandbox.Context()
ctx, _, client := lb.clients.Router.RawClientWithMacAuth(parentCtx)
return ctx, client
func (lb *lndBuiltins) getRawRouterClient() (context.Context, routerrpc.RouterClient) {
parentCtx := lb.engine.sandbox.Context()
ctx := metadata.AppendToOutgoingContext(parentCtx, "macaroon", lb.macaroon)
return ctx, lb.clients.Router.Raw()
}

Comment on lines +309 to +319
if chanPoint != "" {
req.Scope = &lnrpc.PolicyUpdateRequest_ChanPoint{
ChanPoint: &lnrpc.ChannelPoint{
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
FundingTxidStr: chanPoint,
},
},
}
} else {
req.Scope = &lnrpc.PolicyUpdateRequest_Global{Global: true}
}

Choose a reason for hiding this comment

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

critical

The updateChannelPolicy function incorrectly handles the chan_point argument. It passes the entire string as the FundingTxidStr, but chan_point is expected to be in the format txid:index. The output index is missing from the request. This will cause the RPC call to fail. You need to parse the chan_point string to separate the transaction ID and the output index.

You will need to add "strings" and "strconv" to the imports.

	if chanPoint != "" {
		parts := strings.Split(chanPoint, ":")
		if len(parts) != 2 {
			return nil, fmt.Errorf("invalid chan_point format, expected txid:index")
		}
		outputIndex, err := strconv.ParseUint(parts[1], 10, 32)
		if err != nil {
			return nil, fmt.Errorf("invalid output index: %w", err)
		}

		req.Scope = &lnrpc.PolicyUpdateRequest_ChanPoint{
			ChanPoint: &lnrpc.ChannelPoint{
				FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
					FundingTxidStr: parts[0],
				},
				OutputIndex: uint32(outputIndex),
			},
		}
	} else {
		req.Scope = &lnrpc.PolicyUpdateRequest_Global{Global: true}
	}

Comment on lines +142 to +152
// For complex types, use JSON encoding.
goVal, err := fromStarlarkValue(value)
if err != nil {
return nil, fmt.Errorf("cannot convert value to bytes: %w", err)
}
jsonStr, err := toStarlarkValue(goVal)
if err != nil {
return nil, fmt.Errorf("cannot encode value: %w", err)
}
valueBytes = []byte(jsonStr.String())
}

Choose a reason for hiding this comment

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

high

The builtinKVPut function incorrectly encodes complex Starlark types to a string representation instead of JSON. The line jsonStr, err := toStarlarkValue(goVal) converts the Go value back to a Starlark value, and jsonStr.String() produces a Starlark-formatted string (e.g., {"key": "value"}), which is not valid JSON for string keys. You should use json.Marshal to correctly encode the value.

You'll need to add "encoding/json" to the imports.

		// For complex types, use JSON encoding.
		goVal, err := fromStarlarkValue(value)
		if err != nil {
			return nil, fmt.Errorf("cannot convert value to bytes: %w", err)
		}
		valueBytes, err = json.Marshal(goVal)
		if err != nil {
			return nil, fmt.Errorf("cannot encode value to JSON: %w", err)
		}

Comment on lines +852 to +854
AddrToAmount: map[string]int64{
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080": 10000,
},

Choose a reason for hiding this comment

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

high

The estimateFee function uses a hardcoded address and amount. This makes the function not generally useful for scripts. It should be modified to accept an addr_to_amount dictionary from the Starlark script.

Comment on lines +260 to +272
func scriptToProto(script *Script, isRunning bool) *litrpc.Script {
return &litrpc.Script{
Name: script.Name,
Description: script.Description,
Source: script.Source,
TimeoutSecs: script.TimeoutSecs,
MaxMemoryBytes: script.MaxMemoryBytes,
AllowedUrls: script.AllowedURLs,
AllowedBuckets: script.AllowedBuckets,
CreatedAt: uint64(script.CreatedAt.Unix()),
UpdatedAt: uint64(script.UpdatedAt.Unix()),
IsRunning: isRunning,
}

Choose a reason for hiding this comment

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

high

The scriptToProto function is missing the permissions field when converting a Script to its protobuf representation. This means that clients (like the UI or litcli) won't be able to see the permissions a script has after it's been created.

To fix this, you should:

  1. Add a Permissions field (e.g., []*litrpc.MacaroonPermission) to the scripting.Script struct.
  2. Store the permissions in the database when a script is created or updated. You'll need to add a permissions column to the scripts table in the database schema.
  3. Populate the Permissions field in scriptToProto from the scripting.Script struct.

Comment on lines +188 to +191
req := &litrpc.UpdateScriptRequest{
Name: ctx.String("name"),
Description: ctx.String("description"),
}

Choose a reason for hiding this comment

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

medium

In updateScript, the Description field is set on the request unconditionally, which is inconsistent with how other optional flags like perm and timeout are handled using ctx.IsSet(). While the server-side logic currently handles an empty string as "not set", making the client-side explicit by using ctx.IsSet("description") would improve consistency and prevent accidental clearing of the description if the flag is omitted.

Suggested change
req := &litrpc.UpdateScriptRequest{
Name: ctx.String("name"),
Description: ctx.String("description"),
}
req := &litrpc.UpdateScriptRequest{
Name: ctx.String("name"),
}
if ctx.IsSet("description") {
req.Description = ctx.String("description")
}

Comment on lines +701 to +735
func parsePermissions(perms []string) ([]*litrpc.MacaroonPermission, error) {
result := make([]*litrpc.MacaroonPermission, 0, len(perms))

for _, perm := range perms {
// Parse "entity:action" format.
var entity, action string
n, err := fmt.Sscanf(perm, "%s:%s", &entity, &action)
if err != nil || n != 2 {
// Try splitting on colon.
parts := splitOnce(perm, ':')
if len(parts) != 2 {
return nil, fmt.Errorf("invalid permission format '%s', "+
"expected 'entity:action'", perm)
}
entity = parts[0]
action = parts[1]
}

result = append(result, &litrpc.MacaroonPermission{
Entity: entity,
Action: action,
})
}

return result, nil
}

func splitOnce(s string, sep byte) []string {
for i := 0; i < len(s); i++ {
if s[i] == sep {
return []string{s[:i], s[i+1:]}
}
}
return []string{s}
}

Choose a reason for hiding this comment

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

medium

The parsePermissions function and its helper splitOnce are more complex than necessary. You can achieve the same result more robustly and idiomatically using strings.SplitN. This would simplify the code and remove the need for the custom splitOnce helper function.

func parsePermissions(perms []string) ([]*litrpc.MacaroonPermission, error) {
	result := make([]*litrpc.MacaroonPermission, 0, len(perms))

	for _, perm := range perms {
		parts := strings.SplitN(perm, ":", 2)
		if len(parts) != 2 {
			return nil, fmt.Errorf("invalid permission format '%s', "+
				"expected 'entity:action'", perm)
		}

		result = append(result, &litrpc.MacaroonPermission{
			Entity: parts[0],
			Action: parts[1],
		})
	}

	return result, nil
}

Comment on lines +738 to +745
func printScriptJSON(script *litrpc.Script) {
data, err := json.MarshalIndent(script, "", " ")
if err != nil {
fmt.Printf("Error formatting output: %v\n", err)
return
}
fmt.Println(string(data))
}

Choose a reason for hiding this comment

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

medium

The function printScriptJSON is defined but not used anywhere in the file. It should be removed to keep the codebase clean.

Comment on lines +89 to +96
// Try to decode as JSON first for structured data.
starlarkVal, err := toStarlarkValue(string(value))
if err != nil {
// Fall back to returning as bytes.
return starlark.Bytes(value), nil
}

return starlarkVal, nil

Choose a reason for hiding this comment

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

medium

The implementation of builtinKVGet does not correctly handle JSON-encoded values. The comment says it tries to decode as JSON, but the code toStarlarkValue(string(value)) will just convert the raw bytes to a Starlark string, not parse it as JSON. To correctly handle structured data that might have been stored from a script, you should attempt to unmarshal the value as JSON first.

You'll need to add "encoding/json" to the imports.

	// Try to decode as JSON first for structured data.
	var goVal interface{}
	if err := json.Unmarshal(value, &goVal); err == nil {
		starlarkVal, err := toStarlarkValue(goVal)
		if err == nil {
			return starlarkVal, nil
		}
	}

	// Fall back to returning as bytes if not valid JSON or conversion fails.
	return starlark.Bytes(value), nil

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