diff --git a/CLAUDE.md b/CLAUDE.md index 5dd4e2defc..d65c29050e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ This repo contains the CLI for Entire. - `entire/cli/commands`: actual command implementations - `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI, Pi) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md) - `entire/cli/strategy`: strategy implementation (manual-commit) - see section below +- `entire/cli/plugins`: embedded Lua plugin runtime (pure-Go gopher-lua sandbox, lifecycle/git hook bus, capability model, plugin-contributed commands) - see [Lua Plugins](docs/architecture/plugins-lua.md) - `entire/cli/checkpoint`: checkpoint storage abstractions (ephemeral and persistent) - `entire/cli/session`: session state management - `entire/cli/integration_test`: integration tests (simulated hooks) @@ -630,6 +631,32 @@ The phase state machine, metadata directory layout, sharded checkpoint format, m - Test with `mise run test` - strategy tests are in `*_test.go` files - Keep this file and `docs/architecture/sessions-and-checkpoints.md` current when changing strategy behavior (`AGENTS.md` is a symlink to this file) +### Lua Plugins (`cmd/entire/cli/plugins/`) + +Third parties can author no-build-step plugins in Lua (pure-Go gopher-lua, so +`CGO_ENABLED=0` still holds) that subscribe to lifecycle/git hooks and contribute +`entire ` commands, layered on the kubectl-style binary plugins. + +Key invariants when touching this code: + +- Plugins are **inert until allow-listed** (`plugins..enabled = true`). + **Repo-local `.entire/plugins/` plugins may only be enabled from the personal, + uncommitted `.entire/settings.local.json` — never from the committed team + `.entire/settings.json`** (`settings.LocalPluginGrants` + `grantForSource` in + the `plugins` package), so cloning a hostile repo that ships both a plugin and + a team-settings enable cannot run its code. User-global installed plugins use + the merged allow-list (either file). Privileged APIs (`http`/`exec`/`fs`/`net`) + and the mutating hooks (`prepare_commit_msg`, `pre_push`) are + **capability-gated** and fail loud when ungranted. +- The `plugins` package must NOT import `cli` or `strategy` (they import it); + seams build `map[string]any` payloads that the package converts to Lua tables. +- Command resolution order is built-in > Lua command > `entire-` binary. +- Hooks fire best-effort with per-hook timeouts and panic isolation; an observer + must never break a lifecycle event or git hook. + +See [Lua Plugins](docs/architecture/plugins-lua.md) for the full reference and +`examples/plugins/` for working examples. + ### `entire review` Command `entire review` runs a configured review profile. Keep documentation brief and user-facing. diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index a1c2ddab82..625fd98600 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -95,6 +95,23 @@ func DispatchLifecycleEvent(ctx context.Context, ag agent.Agent, event *agent.Ev } } + err := dispatchLifecycleEventHandlers(ctx, ag, event) + + // Dispatch the corresponding observer hook to Lua plugins after the + // framework has handled the event, so plugins observe post-handling state. + // Only on success — a failed handler leaves state the plugin shouldn't act + // on. Best-effort and a no-op when no plugin is enabled. + if err == nil { + firePluginLifecycleHook(ctx, ag, event) + } + return err +} + +// dispatchLifecycleEventHandlers routes an event to its handler. It is split +// from DispatchLifecycleEvent so plugin observer hooks can fire after a +// successful handler without changing the handlers' direct error-propagation +// shape (which several handlers rely on to stay lint-clean). +func dispatchLifecycleEventHandlers(ctx context.Context, ag agent.Agent, event *agent.Event) error { switch event.Type { case agent.SessionStart: return handleLifecycleSessionStart(ctx, ag, event) @@ -944,6 +961,11 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev return fmt.Errorf("failed to save step: %w", err) } + // Observer hook: a session step checkpoint was written. Fired here (rather + // than inside SaveStep) so the strategy package stays decoupled from the + // plugin layer and the payload can draw on the fully-populated step context. + firePluginCheckpointSaved(ctx, stepCtx) + // Update session state with backfilled prompt after SaveStep. // Done after SaveStep because SaveStep may reinitialize session state, // which would overwrite an earlier LastPrompt update. diff --git a/cmd/entire/cli/lua_plugin_hooks.go b/cmd/entire/cli/lua_plugin_hooks.go new file mode 100644 index 0000000000..b9dfdede06 --- /dev/null +++ b/cmd/entire/cli/lua_plugin_hooks.go @@ -0,0 +1,102 @@ +package cli + +import ( + "context" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/plugins" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// lifecycleHookName maps an internal agent lifecycle event to the stable, +// plugin-facing hook name. The mapping is deliberately explicit (not a direct +// enum-to-string) so the internal EventType can evolve without breaking the +// documented plugin surface. Events with no plugin hook (SubagentStart, +// ToolUse) return ok=false. +func lifecycleHookName(t agent.EventType) (string, bool) { + switch t { + case agent.SessionStart: + return plugins.HookSessionStart, true + case agent.TurnStart: + return plugins.HookTurnStart, true + case agent.TurnEnd: + return plugins.HookTurnEnd, true + case agent.Compaction: + return plugins.HookCompaction, true + case agent.SessionEnd: + return plugins.HookSessionEnd, true + case agent.SubagentEnd: + return plugins.HookSubagentEnd, true + case agent.ModelUpdate: + return plugins.HookModelUpdate, true + case agent.SubagentStart, agent.ToolUse: + return "", false + default: + return "", false + } +} + +// firePluginLifecycleHook dispatches the observer hook for a successfully +// handled lifecycle event. Best-effort: FireHook is a no-op when no plugin is +// enabled and never propagates plugin failures. +func firePluginLifecycleHook(ctx context.Context, ag agent.Agent, event *agent.Event) { + hook, ok := lifecycleHookName(event.Type) + if !ok { + return + } + plugins.FireHook(ctx, hook, lifecycleHookPayload(ag, event)) +} + +// lifecycleHookPayload builds the Lua-table payload for a lifecycle hook. It +// exposes operational metadata (ids, agent, model, file paths) but not prompt +// text or file contents — richer, sensitive data is reserved for +// capability-gated APIs. +func lifecycleHookPayload(ag agent.Agent, event *agent.Event) map[string]any { + payload := map[string]any{ + "event": event.Type.String(), + "agent": string(ag.Name()), + } + if event.SessionID != "" { + payload["session_id"] = event.SessionID + } + if event.SessionRef != "" { + payload["session_ref"] = event.SessionRef + } + if event.Model != "" { + payload["model"] = event.Model + } + if len(event.ModifiedFiles) > 0 { + payload["modified_files"] = event.ModifiedFiles + } + if len(event.NewFiles) > 0 { + payload["new_files"] = event.NewFiles + } + if len(event.DeletedFiles) > 0 { + payload["deleted_files"] = event.DeletedFiles + } + if event.SubagentType != "" { + payload["subagent_type"] = event.SubagentType + } + return payload +} + +// firePluginCheckpointSaved dispatches the checkpoint_saved observer hook after +// a session step checkpoint is written. +func firePluginCheckpointSaved(ctx context.Context, stepCtx strategy.StepContext) { + payload := map[string]any{ + "agent": string(stepCtx.AgentType), + } + if stepCtx.SessionID != "" { + payload["session_id"] = stepCtx.SessionID + } + if len(stepCtx.ModifiedFiles) > 0 { + payload["modified_files"] = stepCtx.ModifiedFiles + } + if len(stepCtx.NewFiles) > 0 { + payload["new_files"] = stepCtx.NewFiles + } + if len(stepCtx.DeletedFiles) > 0 { + payload["deleted_files"] = stepCtx.DeletedFiles + } + plugins.FireHook(ctx, plugins.HookCheckpointSaved, payload) +} diff --git a/cmd/entire/cli/lua_plugin_hooks_test.go b/cmd/entire/cli/lua_plugin_hooks_test.go new file mode 100644 index 0000000000..5f0109e035 --- /dev/null +++ b/cmd/entire/cli/lua_plugin_hooks_test.go @@ -0,0 +1,66 @@ +package cli + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/plugins" +) + +func TestLifecycleHookName_Mapping(t *testing.T) { + t.Parallel() + cases := []struct { + in agent.EventType + want string + ok bool + }{ + {agent.SessionStart, plugins.HookSessionStart, true}, + {agent.TurnStart, plugins.HookTurnStart, true}, + {agent.TurnEnd, plugins.HookTurnEnd, true}, + {agent.Compaction, plugins.HookCompaction, true}, + {agent.SessionEnd, plugins.HookSessionEnd, true}, + {agent.SubagentEnd, plugins.HookSubagentEnd, true}, + {agent.ModelUpdate, plugins.HookModelUpdate, true}, + {agent.SubagentStart, "", false}, + {agent.ToolUse, "", false}, + } + for _, c := range cases { + got, ok := lifecycleHookName(c.in) + if got != c.want || ok != c.ok { + t.Errorf("lifecycleHookName(%v) = (%q, %v), want (%q, %v)", c.in, got, ok, c.want, c.ok) + } + } +} + +func TestLifecycleHookPayload_OmitsEmptyAndSensitive(t *testing.T) { + t.Parallel() + ag, err := agent.Get(agent.AgentNameClaudeCode) + if err != nil { + t.Skipf("claude-code agent not registered: %v", err) + } + event := &agent.Event{ + Type: agent.TurnEnd, + SessionID: "sess-1", + Model: "claude-sonnet", + Prompt: "secret prompt text", + ModifiedFiles: []string{"a.go", "b.go"}, + } + payload := lifecycleHookPayload(ag, event) + + if payload["session_id"] != "sess-1" { + t.Errorf("session_id = %v", payload["session_id"]) + } + if payload["model"] != "claude-sonnet" { + t.Errorf("model = %v", payload["model"]) + } + if _, ok := payload["prompt"]; ok { + t.Error("payload must not expose prompt text") + } + if _, ok := payload["session_ref"]; ok { + t.Error("empty session_ref should be omitted") + } + files, ok := payload["modified_files"].([]string) + if !ok || len(files) != 2 { + t.Errorf("modified_files = %v", payload["modified_files"]) + } +} diff --git a/cmd/entire/cli/plugin_group.go b/cmd/entire/cli/plugin_group.go index 64332e012d..65983c1486 100644 --- a/cmd/entire/cli/plugin_group.go +++ b/cmd/entire/cli/plugin_group.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "io" + "os" "github.com/spf13/cobra" ) @@ -17,32 +18,46 @@ import ( func newPluginGroupCmd() *cobra.Command { cmd := &cobra.Command{ Use: "plugin", - Short: "Manage Entire plugins (install, list, remove)", + Short: "Manage Entire plugins (install, update, list, remove)", Long: `Manage Entire plugins. -Plugins are external executables named 'entire-'. The CLI discovers -plugins on $PATH and from a per-user managed directory which is -auto-prepended to PATH at startup. The managed directory is, in order of -precedence: +Two kinds of plugins are supported: - $ENTIRE_PLUGIN_DIR/bin (override) - $XDG_DATA_HOME/entire/plugins/bin (Linux/macOS, when set) - ~/.local/share/entire/plugins/bin (Linux/macOS default) - %LOCALAPPDATA%\entire\plugins\bin (Windows, when set) - ~\AppData\Local\entire\plugins\bin (Windows fallback when LOCALAPPDATA is unset) + - Binary plugins: 'entire-' executables discovered on $PATH and in a + per-user managed bin/ dir that is auto-prepended to PATH at startup. + - Lua plugins: no-build-step scripts (a plugin.json + main.lua) that subscribe + to lifecycle/git hooks and contribute commands, run in a sandboxed embedded + interpreter. Installed under the managed lua/ dir. See + docs/architecture/plugins-lua.md. + +A Lua plugin is inert until you allow-list it in .entire/settings.json +(plugins..enabled = true) and grant any capabilities it needs; installing +alone does not run it. Repo-local .entire/plugins/ plugins never auto-run. + +The managed parent directory is, in order of precedence: + + $ENTIRE_PLUGIN_DIR (override) + $XDG_DATA_HOME/entire/plugins (Linux/macOS, when set) + ~/.local/share/entire/plugins (Linux/macOS default) + %LOCALAPPDATA%\entire\plugins (Windows, when set) + ~\AppData\Local\entire\plugins (Windows fallback when LOCALAPPDATA is unset) Commands: - install Install a plugin by linking or copying an existing executable - list List plugins installed in the managed directory - remove Remove a plugin from the managed directory + install Install a plugin from a git URL, a directory, or an executable + update Update git-installed Lua plugins + list List installed Lua and binary plugins + remove Remove an installed plugin Examples: + entire plugin install https://github.com/acme/entire-notify.git --ref v1.0.0 + entire plugin install ./examples/plugins/checkpoint-notify entire plugin install ./dist/entire-pgr entire plugin list - entire plugin remove pgr`, + entire plugin remove checkpoint-notify`, } cmd.AddCommand(newPluginInstallCmd()) + cmd.AddCommand(newPluginUpdateCmd()) cmd.AddCommand(newPluginListCmd()) cmd.AddCommand(newPluginRemoveCmd()) return cmd @@ -50,29 +65,55 @@ Examples: func newPluginInstallCmd() *cobra.Command { var force bool + var ref string cmd := &cobra.Command{ - Use: "install ", - Short: "Link or copy a plugin executable into the managed directory", - Long: `Link or copy a plugin executable into the managed directory. + Use: "install ", + Short: "Install a plugin from a git URL, a directory, or an executable", + Long: `Install a plugin. The source may be: -The source must be a file whose basename starts with 'entire-' (the -dispatcher only resolves names of that shape). On Unix the file must be -executable. + - a git URL (https://…, git@…, …/repo.git): the repo is cloned into the + managed Lua plugin directory, keyed by its plugin.json "name". Use --ref to + pin a tag, branch, or commit. + - a directory containing a plugin.json: copied into the managed Lua plugin + directory as a Lua plugin. + - a file named 'entire-': linked/copied into the managed bin directory + as a kubectl-style binary plugin. -The CLI prefers a symlink so rebuilds of the source are reflected -immediately, and falls back to a hardlink, then a copy, if symlinks aren't -available (notably Windows without Developer Mode). - -After install, 'entire ' invokes the plugin via the kubectl-style -dispatcher — the managed directory is auto-prepended to $PATH. +Installing only places files. A Lua plugin stays inert until you allow-list it +in .entire/settings.json (plugins..enabled = true) and grant any +capabilities it needs — installing an untrusted URL cannot run its code. Examples: - entire plugin install ./dist/entire-pgr - entire plugin install /usr/local/bin/entire-pgr --force`, + entire plugin install https://github.com/acme/entire-notify.git + entire plugin install https://github.com/acme/entire-notify.git --ref v1.2.0 + entire plugin install ./my-plugin # directory with plugin.json + entire plugin install ./dist/entire-pgr # binary plugin`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + src := args[0] + + if looksLikeGitURL(src) { + p, err := InstallLuaPluginFromGit(cmd.Context(), src, ref, force) + if err != nil { + return fmt.Errorf("install plugin: %w", err) + } + printLuaInstalled(cmd, p) + return nil + } + if info, statErr := os.Stat(src); statErr == nil && info.IsDir() { + p, err := InstallLuaPluginFromPath(cmd.Context(), src, force) + if err != nil { + return fmt.Errorf("install plugin: %w", err) + } + printLuaInstalled(cmd, p) + return nil + } + + if ref != "" { + return fmt.Errorf("--ref applies only to git installs, not %q", src) + } p, err := InstallPluginFromPath(InstallPluginOptions{ - SourcePath: args[0], + SourcePath: src, Force: force, }) if err != nil { @@ -84,9 +125,69 @@ Examples: }, } cmd.Flags().BoolVar(&force, "force", false, "Replace an existing entry with the same name") + cmd.Flags().StringVar(&ref, "ref", "", "Pin a git tag, branch, or commit (git installs only)") return cmd } +// printLuaInstalled reports a successful Lua plugin install plus the opt-in hint +// that it will not run until allow-listed. +func printLuaInstalled(cmd *cobra.Command, p *InstalledLuaPlugin) { + fmt.Fprintf(cmd.OutOrStdout(), "Installed Lua plugin %q → %s\n", p.Name, p.Dir) + fmt.Fprintf(cmd.ErrOrStderr(), + "Not enabled yet. To activate, add this to .entire/settings.json:\n"+ + " \"plugins\": { %q: { \"enabled\": true } }\n", p.Name) +} + +func newPluginUpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update [name]", + Short: "Update git-installed Lua plugins", + Long: `Update Lua plugins that were installed from a git URL. + +With a name, updates that plugin; with no name, updates all git-installed Lua +plugins. A plugin pinned with --ref is updated to the latest commit for that +tag/branch (a pinned commit stays put). Plugins installed from a local path or +as binaries cannot be updated this way — reinstall them instead.`, + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + if err := UpdateLuaPlugin(cmd.Context(), args[0]); err != nil { + return fmt.Errorf("update plugin: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Updated Lua plugin %q\n", args[0]) + return nil + } + return runUpdateAllLuaPlugins(cmd) + }, + } + return cmd +} + +// runUpdateAllLuaPlugins updates every git-installed Lua plugin, reporting +// per-plugin results and skipping non-git installs. +func runUpdateAllLuaPlugins(cmd *cobra.Command) error { + all, err := ListInstalledLuaPlugins() + if err != nil { + return fmt.Errorf("list lua plugins: %w", err) + } + updated := 0 + for _, p := range all { + if p.Type != "git" { + continue + } + if err := UpdateLuaPlugin(cmd.Context(), p.Name); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " %-20s update failed: %v\n", p.Name, err) + continue + } + fmt.Fprintf(cmd.OutOrStdout(), " %-20s updated\n", p.Name) + updated++ + } + if updated == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No git-installed Lua plugins to update.") + } + return nil +} + // warnIfShadowsBuiltin prints a one-line note to stderr when the just-installed // plugin name matches a built-in command. The dispatcher's resolvePlugin gates // dispatch on rootCmd.Find, so the built-in always wins at runtime — without @@ -116,25 +217,48 @@ func newPluginListCmd() *cobra.Command { } func runPluginList(w io.Writer) error { - plugins, err := ListInstalledPlugins() + binPlugins, err := ListInstalledPlugins() if err != nil { return fmt.Errorf("list plugins: %w", err) } + luaPlugins, err := ListInstalledLuaPlugins() + if err != nil { + return fmt.Errorf("list lua plugins: %w", err) + } dir, err := PluginBinDir() if err != nil { return fmt.Errorf("plugin bin dir: %w", err) } - if len(plugins) == 0 { + + if len(binPlugins) == 0 && len(luaPlugins) == 0 { fmt.Fprintf(w, "No plugins installed in %s.\n", dir) - fmt.Fprintln(w, "Install one with 'entire plugin install ', or drop an entire- binary anywhere on $PATH.") + fmt.Fprintln(w, "Install one with 'entire plugin install ', or drop an entire- binary anywhere on $PATH.") return nil } - fmt.Fprintf(w, "Managed plugin directory: %s\n\n", dir) - for _, p := range plugins { - if p.Symlink { - fmt.Fprintf(w, " %-20s → %s\n", p.Name, p.LinkTarget) - } else { - fmt.Fprintf(w, " %-20s %s\n", p.Name, p.Path) + + if len(luaPlugins) > 0 { + fmt.Fprintln(w, "Lua plugins:") + for _, p := range luaPlugins { + src := p.Source + if p.Ref != "" { + src = fmt.Sprintf("%s @ %s", p.Source, p.Ref) + } + if src == "" { + src = p.Dir + } + fmt.Fprintf(w, " %-20s %s\n", p.Name, src) + } + fmt.Fprintln(w) + } + + if len(binPlugins) > 0 { + fmt.Fprintf(w, "Binary plugins (managed dir %s):\n", dir) + for _, p := range binPlugins { + if p.Symlink { + fmt.Fprintf(w, " %-20s → %s\n", p.Name, p.LinkTarget) + } else { + fmt.Fprintf(w, " %-20s %s\n", p.Name, p.Path) + } } } return nil @@ -146,14 +270,32 @@ func newPluginRemoveCmd() *cobra.Command { Short: "Remove a plugin from the managed directory", Long: `Remove a plugin from the managed directory. -Only entries in the managed directory are affected. Plugins installed by -dropping a binary elsewhere on $PATH are unmanaged — remove those by hand.`, +Removes a managed Lua plugin (under the lua/ dir) or a managed binary plugin +(under the bin/ dir). Plugins installed by dropping a binary elsewhere on $PATH +are unmanaged — remove those by hand. Removing does not touch the plugin's +allow-list entry in settings; delete that too to fully deactivate it.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := RemoveInstalledPlugin(args[0]); err != nil { + name := args[0] + + // Prefer the Lua store (the newer surface); fall back to the binary + // store so both kinds are removable by bare name. + lua, err := FindInstalledLuaPlugin(name) + if err != nil { + return fmt.Errorf("remove plugin: %w", err) + } + if lua != nil { + if err := RemoveLuaPlugin(name); err != nil { + return fmt.Errorf("remove plugin: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed Lua plugin %q\n", name) + return nil + } + + if err := RemoveInstalledPlugin(name); err != nil { return fmt.Errorf("remove plugin: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Removed plugin %q\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Removed plugin %q\n", name) return nil }, } diff --git a/cmd/entire/cli/plugin_lua_command.go b/cmd/entire/cli/plugin_lua_command.go new file mode 100644 index 0000000000..95a8935c06 --- /dev/null +++ b/cmd/entire/cli/plugin_lua_command.go @@ -0,0 +1,40 @@ +package cli + +import ( + "context" + + "github.com/entireio/cli/cmd/entire/cli/plugins" + "github.com/spf13/cobra" +) + +// MaybeRunLuaCommand resolves `entire ` to a Lua-plugin-contributed +// command and runs it. It enforces the resolution order built-in > Lua command +// > `entire-` binary: a built-in always wins (this returns not-handled so +// Cobra runs it), and it is invoked from main.go BEFORE the binary plugin +// dispatcher so a Lua command wins over a same-named binary plugin. +// +// Returns (true, exitCode) when a Lua command handled the invocation, else +// (false, 0). Plugins are only loaded when the first arg is a plugin-shaped, +// non-built-in name, so built-in commands never pay the discovery cost. +func MaybeRunLuaCommand(ctx context.Context, rootCmd *cobra.Command, args []string) (handled bool, exitCode int) { + if len(args) == 0 { + return false, 0 + } + name := args[0] + if !isPluginCandidate(name) { + return false, 0 + } + // Built-in commands always win. Prime help/completion so their names aren't + // shadowed, mirroring the binary dispatcher's resolvePlugin. + rootCmd.InitDefaultHelpCmd() + rootCmd.InitDefaultCompletionCmd(args...) + if cmd, _, err := rootCmd.Find(args); err == nil && cmd != rootCmd { + return false, 0 + } + + code, found := plugins.RunCommand(ctx, name, args[1:]) + if !found { + return false, 0 + } + return true, code +} diff --git a/cmd/entire/cli/plugin_lua_command_test.go b/cmd/entire/cli/plugin_lua_command_test.go new file mode 100644 index 0000000000..3f6b8fac04 --- /dev/null +++ b/cmd/entire/cli/plugin_lua_command_test.go @@ -0,0 +1,36 @@ +package cli + +import ( + "context" + "testing" +) + +func TestMaybeRunLuaCommand_BuiltinWins(t *testing.T) { + t.Parallel() + rootCmd := NewRootCmd() + // "status" is a built-in; the Lua dispatcher must defer to it (not handle). + handled, code := MaybeRunLuaCommand(context.Background(), rootCmd, []string{"status"}) + if handled { + t.Error("built-in command must not be handled by the Lua dispatcher") + } + if code != 0 { + t.Errorf("exit code = %d, want 0 for not-handled", code) + } +} + +func TestMaybeRunLuaCommand_IgnoresFlagShapedArgs(t *testing.T) { + t.Parallel() + rootCmd := NewRootCmd() + handled, code := MaybeRunLuaCommand(context.Background(), rootCmd, []string{"--help"}) + if handled || code != 0 { + t.Errorf("flag-shaped first arg must not be handled, got (%v, %d)", handled, code) + } +} + +func TestMaybeRunLuaCommand_EmptyArgs(t *testing.T) { + t.Parallel() + rootCmd := NewRootCmd() + if handled, code := MaybeRunLuaCommand(context.Background(), rootCmd, nil); handled || code != 0 { + t.Errorf("no args must not be handled, got (%v, %d)", handled, code) + } +} diff --git a/cmd/entire/cli/plugin_lua_store.go b/cmd/entire/cli/plugin_lua_store.go new file mode 100644 index 0000000000..d6d5b21fb2 --- /dev/null +++ b/cmd/entire/cli/plugin_lua_store.go @@ -0,0 +1,377 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/plugins" +) + +// Lua plugin distribution. Lua plugins live one directory per plugin under the +// managed lua/ tree (see plugins.UserLuaPluginsDir), a sibling of the binary +// store's bin/ and data/ dirs so the two never collide. Installing only places +// files; a plugin stays inert until it is allow-listed and enabled in settings, +// so `install` is safe to run on an untrusted URL — the code cannot run until +// the user explicitly opts in. + +// luaInstallMetaFile records how a Lua plugin was installed so `update` can +// reproduce the pin. +const luaInstallMetaFile = ".entire-install.json" + +// luaInstallMeta is persisted in each installed plugin dir. +type luaInstallMeta struct { + Source string `json:"source"` // git URL or local path + Ref string `json:"ref,omitempty"` // pinned tag/branch/sha (git installs) + Type string `json:"type"` // "git" or "local" +} + +// InstalledLuaPlugin describes a Lua plugin in the managed lua/ dir. +type InstalledLuaPlugin struct { + Name string + Dir string + Version string + Source string + Ref string + Type string +} + +// looksLikeGitURL reports whether src should be treated as a git remote rather +// than a local path. +func looksLikeGitURL(src string) bool { + if strings.HasSuffix(src, ".git") || strings.HasPrefix(src, "git@") { + return true + } + for _, scheme := range []string{"http://", "https://", "git://", "ssh://"} { + if strings.HasPrefix(src, scheme) { + return true + } + } + return false +} + +// ListInstalledLuaPlugins enumerates plugin dirs under the managed lua/ tree. +// A missing dir returns no error and an empty slice. +func ListInstalledLuaPlugins() ([]*InstalledLuaPlugin, error) { + luaDir, err := plugins.UserLuaPluginsDir() + if err != nil { + return nil, fmt.Errorf("resolve lua plugin dir: %w", err) + } + entries, err := os.ReadDir(luaDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read lua plugin dir: %w", err) + } + var out []*InstalledLuaPlugin + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + dir := filepath.Join(luaDir, e.Name()) + manifest, err := plugins.LoadManifestFromDir(dir) + if err != nil { + continue // not a valid plugin dir + } + meta := readLuaInstallMeta(dir) + out = append(out, &InstalledLuaPlugin{ + Name: manifest.Name, + Dir: dir, + Version: manifest.Version, + Source: meta.Source, + Ref: meta.Ref, + Type: meta.Type, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// FindInstalledLuaPlugin returns the installed Lua plugin with the given name, +// or nil when not installed. +func FindInstalledLuaPlugin(name string) (*InstalledLuaPlugin, error) { + all, err := ListInstalledLuaPlugins() + if err != nil { + return nil, err + } + for _, p := range all { + if p.Name == name { + return p, nil + } + } + return nil, nil //nolint:nilnil // not-installed signal +} + +// InstallLuaPluginFromGit clones url into the managed lua/ tree, keyed by the +// cloned plugin's manifest name. An optional ref pins a tag, branch, or commit. +func InstallLuaPluginFromGit(ctx context.Context, url, ref string, force bool) (*InstalledLuaPlugin, error) { + luaDir, err := ensureLuaPluginsDir() + if err != nil { + return nil, err + } + tmp, err := os.MkdirTemp(luaDir, ".clone-") + if err != nil { + return nil, fmt.Errorf("create clone temp dir: %w", err) + } + cleanupTmp := true + defer func() { + if cleanupTmp { + _ = os.RemoveAll(tmp) + } + }() + + if err := cloneGitPlugin(ctx, url, ref, tmp); err != nil { + return nil, err + } + manifest, err := plugins.LoadManifestFromDir(tmp) + if err != nil { + return nil, fmt.Errorf("cloned source is not a valid plugin: %w", err) + } + + dest, err := placeLuaPlugin(luaDir, manifest.Name, tmp, force) + if err != nil { + return nil, err + } + cleanupTmp = false // renamed into place + + writeLuaInstallMeta(dest, luaInstallMeta{Source: url, Ref: ref, Type: "git"}) + return FindInstalledLuaPlugin(manifest.Name) +} + +// InstallLuaPluginFromPath copies a local plugin directory into the managed +// lua/ tree, keyed by the manifest name. +func InstallLuaPluginFromPath(ctx context.Context, srcDir string, force bool) (*InstalledLuaPlugin, error) { + manifest, err := plugins.LoadManifestFromDir(srcDir) + if err != nil { + return nil, fmt.Errorf("source is not a valid plugin: %w", err) + } + luaDir, err := ensureLuaPluginsDir() + if err != nil { + return nil, err + } + tmp, err := os.MkdirTemp(luaDir, ".copy-") + if err != nil { + return nil, fmt.Errorf("create copy temp dir: %w", err) + } + cleanupTmp := true + defer func() { + if cleanupTmp { + _ = os.RemoveAll(tmp) + } + }() + + if err := copyPluginDir(ctx, srcDir, tmp); err != nil { + return nil, err + } + dest, err := placeLuaPlugin(luaDir, manifest.Name, tmp, force) + if err != nil { + return nil, err + } + cleanupTmp = false + + absSrc, _ := filepath.Abs(srcDir) //nolint:errcheck // best-effort for metadata display + writeLuaInstallMeta(dest, luaInstallMeta{Source: absSrc, Type: "local"}) + return FindInstalledLuaPlugin(manifest.Name) +} + +// UpdateLuaPlugin updates a git-installed Lua plugin: it fetches and, when a ref +// is pinned, checks it out (fast-forwarding a pinned branch); otherwise it +// fast-forward pulls the default branch. Local-path installs cannot be updated. +func UpdateLuaPlugin(ctx context.Context, name string) error { + p, err := FindInstalledLuaPlugin(name) + if err != nil { + return err + } + if p == nil { + return fmt.Errorf("lua plugin %q is not installed", name) + } + if _, statErr := os.Stat(filepath.Join(p.Dir, ".git")); statErr != nil { + return fmt.Errorf("lua plugin %q was not installed from git; reinstall to update", name) + } + if err := runPluginGit(ctx, p.Dir, "fetch", "--tags", "--prune", "origin"); err != nil { + return err + } + if p.Ref != "" { + // p.Ref is read from the plugin's install metadata; validate it here too + // so a tampered .entire-install.json cannot smuggle a "-"-prefixed value + // into git checkout as an option. + if err := validateGitArg("ref", p.Ref); err != nil { + return err + } + if err := runPluginGit(ctx, p.Dir, "checkout", "--quiet", p.Ref); err != nil { + return err + } + // Fast-forward a pinned branch to its upstream; a no-op (ignored error) + // for a pinned tag or commit, which have no origin/. + _ = runPluginGit(ctx, p.Dir, "merge", "--ff-only", "origin/"+p.Ref) //nolint:errcheck // pinned tag/sha has no upstream branch; best-effort ff + return nil + } + if err := runPluginGit(ctx, p.Dir, "pull", "--ff-only"); err != nil { + return err + } + return nil +} + +// RemoveLuaPlugin removes an installed Lua plugin's directory. +func RemoveLuaPlugin(name string) error { + if err := plugins.ValidatePluginName(name); err != nil { + return fmt.Errorf("invalid plugin name: %w", err) + } + p, err := FindInstalledLuaPlugin(name) + if err != nil { + return err + } + if p == nil { + return fmt.Errorf("lua plugin %q is not installed", name) + } + if err := os.RemoveAll(p.Dir); err != nil { + return fmt.Errorf("remove lua plugin dir: %w", err) + } + return nil +} + +// ensureLuaPluginsDir resolves and creates the managed lua/ dir. +func ensureLuaPluginsDir() (string, error) { + luaDir, err := plugins.UserLuaPluginsDir() + if err != nil { + return "", fmt.Errorf("resolve lua plugin dir: %w", err) + } + if err := os.MkdirAll(luaDir, 0o750); err != nil { + return "", fmt.Errorf("create lua plugin dir: %w", err) + } + return luaDir, nil +} + +// placeLuaPlugin atomically moves a staged plugin dir (tmp) to lua/, +// honoring force for an existing install. +func placeLuaPlugin(luaDir, name, tmp string, force bool) (string, error) { + if err := plugins.ValidatePluginName(name); err != nil { + return "", fmt.Errorf("plugin name %q is not dispatchable: %w", name, err) + } + dest := filepath.Join(luaDir, name) + if _, err := os.Stat(dest); err == nil { + if !force { + return "", fmt.Errorf("lua plugin %q already installed at %s; use --force to replace", name, dest) + } + if err := os.RemoveAll(dest); err != nil { + return "", fmt.Errorf("replace existing plugin: %w", err) + } + } + if err := os.Rename(tmp, dest); err != nil { + return "", fmt.Errorf("install plugin: %w", err) + } + return dest, nil +} + +// validateGitArg rejects a url/ref that could be misparsed as a git option. A +// value beginning with "-" (e.g. "--upload-pack=payload" or "-oProxyCommand=…") +// would be consumed by git as a flag rather than as the intended positional +// argument, turning a plugin install into arbitrary command execution. We reject +// such values loudly. Callers additionally pass "--" before positional +// arguments where the subcommand supports it (git clone), but "git checkout"'s +// "--" introduces a pathspec rather than a ref, so leading-dash rejection is the +// load-bearing guard for refs. +func validateGitArg(kind, value string) error { + if strings.HasPrefix(value, "-") { + return fmt.Errorf("invalid plugin %s %q: must not start with '-'", kind, value) + } + return nil +} + +// cloneGitPlugin clones url into dest. A ref triggers a full clone + checkout +// (so tags, branches, and commits all work); no ref does a shallow clone of the +// default branch. url and ref are validated and the clone passes "--" before its +// positional arguments so neither can be interpreted as a git option. +func cloneGitPlugin(ctx context.Context, url, ref, dest string) error { + if err := validateGitArg("url", url); err != nil { + return err + } + if ref == "" { + return runPluginGit(ctx, "", "clone", "--depth", "1", "--", url, dest) + } + if err := validateGitArg("ref", ref); err != nil { + return err + } + if err := runPluginGit(ctx, "", "clone", "--", url, dest); err != nil { + return err + } + // "git checkout " cannot use "--" (that would select a pathspec), so + // the leading-dash validation above is what keeps ref from being parsed as + // an option here. + return runPluginGit(ctx, dest, "checkout", "--quiet", ref) +} + +// copyPluginDir copies a plugin source dir into dest, excluding any .git dir. +func copyPluginDir(ctx context.Context, src, dest string) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("copy plugin: %w", err) + } + entries, err := os.ReadDir(src) + if err != nil { + return fmt.Errorf("read plugin source: %w", err) + } + for _, e := range entries { + if e.Name() == ".git" { + continue + } + from := filepath.Join(src, e.Name()) + to := filepath.Join(dest, e.Name()) + if e.IsDir() { + if err := os.MkdirAll(to, 0o750); err != nil { + return fmt.Errorf("create %s: %w", to, err) + } + if err := copyPluginDir(ctx, from, to); err != nil { + return err + } + continue + } + data, err := os.ReadFile(from) //nolint:gosec // from is under the user-provided plugin source dir being installed + if err != nil { + return fmt.Errorf("read %s: %w", from, err) + } + //nolint:gosec // to is under the managed staging dir; the name comes from the user's own plugin source being installed + if err := os.WriteFile(to, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", to, err) + } + } + return nil +} + +// runPluginGit runs a git command in dir (or the process cwd when dir is +// empty). Errors include the git output for diagnosis. +func runPluginGit(ctx context.Context, dir string, args ...string) error { + cmd := exec.CommandContext(ctx, "git", args...) + if dir != "" { + cmd.Dir = dir + } + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} + +func readLuaInstallMeta(dir string) luaInstallMeta { + var meta luaInstallMeta + data, err := os.ReadFile(filepath.Join(dir, luaInstallMetaFile)) //nolint:gosec // path inside the managed plugin dir + if err != nil { + return meta + } + _ = json.Unmarshal(data, &meta) //nolint:errcheck // best-effort; absent/corrupt metadata just yields empty fields + return meta +} + +func writeLuaInstallMeta(dir string, meta luaInstallMeta) { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return + } + _ = os.WriteFile(filepath.Join(dir, luaInstallMetaFile), data, 0o600) //nolint:errcheck // metadata is advisory; a write failure only degrades `update` +} diff --git a/cmd/entire/cli/plugin_lua_store_test.go b/cmd/entire/cli/plugin_lua_store_test.go new file mode 100644 index 0000000000..65faaa8a35 --- /dev/null +++ b/cmd/entire/cli/plugin_lua_store_test.go @@ -0,0 +1,180 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +func TestLooksLikeGitURL(t *testing.T) { + t.Parallel() + git := []string{ + "https://github.com/acme/entire-notify.git", + "http://example.com/x.git", + "git@github.com:acme/repo.git", + "ssh://git@host/repo", + "git://host/repo", + } + notGit := []string{ + "./local/dir", + "/abs/path", + "entire-pgr", + "my-plugin", + } + for _, u := range git { + if !looksLikeGitURL(u) { + t.Errorf("looksLikeGitURL(%q) = false, want true", u) + } + } + for _, u := range notGit { + if looksLikeGitURL(u) { + t.Errorf("looksLikeGitURL(%q) = true, want false", u) + } + } +} + +func TestValidateGitArg(t *testing.T) { + t.Parallel() + bad := []string{"-x", "--upload-pack=payload", "-oProxyCommand=x", "--evil"} + for _, v := range bad { + if err := validateGitArg("url", v); err == nil { + t.Errorf("validateGitArg(url, %q) = nil, want error", v) + } + } + ok := []string{ + "https://github.com/acme/x.git", "git@github.com:acme/x.git", + "v1.2.0", "main", "abc123", "./local", "/abs/path", + } + for _, v := range ok { + if err := validateGitArg("ref", v); err != nil { + t.Errorf("validateGitArg(ref, %q) = %v, want nil", v, err) + } + } +} + +func TestCloneGitPlugin_RejectsDashArgs(t *testing.T) { + t.Parallel() + dest := filepath.Join(t.TempDir(), "dest") + // A dash-prefixed url must be rejected before any git process runs, so a + // value like "--upload-pack=" cannot smuggle in command execution. + if err := cloneGitPlugin(context.Background(), "--upload-pack=touch pwned", "", dest); err == nil { + t.Error("expected error for dash-prefixed url") + } + // A valid url but dash-prefixed ref must also be rejected (before clone). + if err := cloneGitPlugin(context.Background(), "https://example.com/x.git", "--evil", dest); err == nil { + t.Error("expected error for dash-prefixed ref") + } +} + +func TestInstallLuaPluginFromGit_RejectsDashURL(t *testing.T) { + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + _, err := InstallLuaPluginFromGit(context.Background(), "--upload-pack=payload", "", false) + if err == nil || !strings.Contains(err.Error(), "must not start with '-'") { + t.Fatalf("expected dash-url rejection, got %v", err) + } +} + +func writeLocalPluginDir(t *testing.T, name, mainLua string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "src-"+name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + manifest := `{"name":"` + name + `","version":"1.0.0"}` + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "main.lua"), []byte(mainLua), 0o600); err != nil { + t.Fatalf("write main.lua: %v", err) + } + return dir +} + +func TestInstallLuaPluginFromPath_ListAndRemove(t *testing.T) { + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + src := writeLocalPluginDir(t, "notify", `-- noop`) + + p, err := InstallLuaPluginFromPath(context.Background(), src, false) + if err != nil { + t.Fatalf("InstallLuaPluginFromPath() error = %v", err) + } + if p.Name != "notify" || p.Type != "local" { + t.Errorf("installed = %+v, want name=notify type=local", p) + } + + all, err := ListInstalledLuaPlugins() + if err != nil { + t.Fatalf("ListInstalledLuaPlugins() error = %v", err) + } + if len(all) != 1 || all[0].Name != "notify" { + t.Fatalf("list = %+v, want [notify]", all) + } + + // Re-install without --force must fail; with force must succeed. + if _, err := InstallLuaPluginFromPath(context.Background(), src, false); err == nil { + t.Error("expected re-install without --force to fail") + } + if _, err := InstallLuaPluginFromPath(context.Background(), src, true); err != nil { + t.Errorf("force re-install failed: %v", err) + } + + if err := RemoveLuaPlugin("notify"); err != nil { + t.Fatalf("RemoveLuaPlugin() error = %v", err) + } + found, ferr := FindInstalledLuaPlugin("notify") + if ferr != nil { + t.Fatalf("FindInstalledLuaPlugin() error = %v", ferr) + } + if found != nil { + t.Error("expected plugin removed") + } +} + +func TestInstallLuaPluginFromGit_AndUpdate(t *testing.T) { + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + ctx := context.Background() + + // Build a local git repo to act as the remote source. + srcRepo := t.TempDir() + testutil.InitRepo(t, srcRepo) + testutil.WriteFile(t, srcRepo, "plugin.json", `{"name":"gitplug","version":"1.0.0"}`) + testutil.WriteFile(t, srcRepo, "main.lua", `-- v1`) + testutil.GitAdd(t, srcRepo, "plugin.json", "main.lua") + testutil.GitCommit(t, srcRepo, "initial") + + p, err := InstallLuaPluginFromGit(ctx, srcRepo, "", false) + if err != nil { + t.Fatalf("InstallLuaPluginFromGit() error = %v", err) + } + if p.Name != "gitplug" || p.Type != "git" { + t.Errorf("installed = %+v, want name=gitplug type=git", p) + } + if got := readInstalled(t, p.Dir, "main.lua"); got != "-- v1" { + t.Errorf("main.lua = %q, want -- v1", got) + } + + // Advance the source, then update the installed plugin. + testutil.WriteFile(t, srcRepo, "main.lua", `-- v2`) + testutil.GitAdd(t, srcRepo, "main.lua") + testutil.GitCommit(t, srcRepo, "update") + + if err := UpdateLuaPlugin(ctx, "gitplug"); err != nil { + t.Fatalf("UpdateLuaPlugin() error = %v", err) + } + if got := readInstalled(t, p.Dir, "main.lua"); got != "-- v2" { + t.Errorf("after update main.lua = %q, want -- v2", got) + } +} + +func readInstalled(t *testing.T, dir, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return string(data) +} diff --git a/cmd/entire/cli/plugins/api.go b/cmd/entire/cli/plugins/api.go new file mode 100644 index 0000000000..4c1a3e9304 --- /dev/null +++ b/cmd/entire/cli/plugins/api.go @@ -0,0 +1,177 @@ +package plugins + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/logging" + lua "github.com/yuin/gopher-lua" +) + +// entireModuleName is the global table plugins use to reach the host API. +const entireModuleName = "entire" + +// installAPI registers the `entire` module (and a routed print) into the +// plugin's Lua state. Called once after the sandbox is built and before the +// entry script runs, so entire.on/entire.log are available during setup. +func (p *LoadedPlugin) installAPI(ls *lua.LState) { + mod := ls.NewTable() + + ls.SetField(mod, "on", ls.NewFunction(p.luaOn)) + + // Read-only accessors. Strings are copied by value into Lua, so a plugin + // cannot mutate host state through them. + ls.SetField(mod, "plugin_name", lua.LString(p.Manifest.Name)) + ls.SetField(mod, "version", lua.LString(p.Manifest.Version)) + ls.SetField(mod, "source", lua.LString(string(p.Source))) + ls.SetField(mod, "repo_root", lua.LString(p.WorktreeRoot)) + if p.kv != nil { + ls.SetField(mod, "data_dir", lua.LString(p.kv.dir)) + } + + logTbl := ls.NewTable() + ls.SetField(logTbl, "debug", ls.NewFunction(p.luaLog(slog.LevelDebug))) + ls.SetField(logTbl, "info", ls.NewFunction(p.luaLog(slog.LevelInfo))) + ls.SetField(logTbl, "warn", ls.NewFunction(p.luaLog(slog.LevelWarn))) + ls.SetField(logTbl, "error", ls.NewFunction(p.luaLog(slog.LevelError))) + ls.SetField(mod, "log", logTbl) + + kvTbl := ls.NewTable() + ls.SetField(kvTbl, "get", ls.NewFunction(p.luaKVGet)) + ls.SetField(kvTbl, "set", ls.NewFunction(p.luaKVSet)) + ls.SetField(kvTbl, "delete", ls.NewFunction(p.luaKVDelete)) + ls.SetField(mod, "kv", kvTbl) + + // entire.json is pure/deterministic (it touches nothing outside the Lua + // state), so it lives in the always-available surface, not behind a + // capability. See json.go. + jsonTbl := ls.NewTable() + ls.SetField(jsonTbl, "decode", ls.NewFunction(luaJSONDecode)) + ls.SetField(jsonTbl, "encode", ls.NewFunction(luaJSONEncode)) + ls.SetField(mod, "json", jsonTbl) + + // Command contribution and stdout output (for plugin commands). + ls.SetField(mod, "command", ls.NewFunction(p.luaCommand)) + ls.SetField(mod, "print", ls.NewFunction(luaStdoutPrint(true))) + ls.SetField(mod, "write", ls.NewFunction(luaStdoutPrint(false))) + + p.installCapabilityAPI(ls, mod) + + ls.SetGlobal(entireModuleName, mod) + + // Re-provide print, routed to the plugin logger at info level, so authors + // can debug with print() without corrupting hook stdout (the sandbox + // stripped the stdout-writing base print). + ls.SetGlobal("print", ls.NewFunction(func(l *lua.LState) int { + top := l.GetTop() + var sb strings.Builder + for i := 1; i <= top; i++ { + if i > 1 { + sb.WriteByte('\t') + } + sb.WriteString(l.ToStringMeta(l.Get(i)).String()) + } + p.log(slog.LevelInfo, sb.String()) + return 0 + })) +} + +// luaOn implements entire.on(hook, callback): it records callback as a +// subscriber for the named hook. Unknown hook names are a hard error so a typo +// is caught at load time. +func (p *LoadedPlugin) luaOn(ls *lua.LState) int { + hook := ls.CheckString(1) + cb := ls.CheckFunction(2) + if !IsKnownHook(hook) { + ls.ArgError(1, fmt.Sprintf("unknown hook %q", hook)) + return 0 + } + p.callbacks[hook] = append(p.callbacks[hook], cb) + return 0 +} + +// luaLog returns an entire.log. binding. +func (p *LoadedPlugin) luaLog(level slog.Level) lua.LGFunction { + return func(ls *lua.LState) int { + msg := ls.CheckString(1) + p.log(level, msg) + return 0 + } +} + +// luaKVGet implements entire.kv.get(key) -> string|nil. It returns nil when +// the key is absent and raises a Lua error on a storage failure. +func (p *LoadedPlugin) luaKVGet(ls *lua.LState) int { + key := ls.CheckString(1) + if p.kv == nil { + ls.Push(lua.LNil) + return 1 + } + v, ok, err := p.kv.get(key) + if err != nil { + ls.RaiseError("entire.kv.get(%q): %v", key, err) + return 0 + } + if !ok { + ls.Push(lua.LNil) + return 1 + } + ls.Push(lua.LString(v)) + return 1 +} + +// luaKVSet implements entire.kv.set(key, value). value is coerced to a string. +func (p *LoadedPlugin) luaKVSet(ls *lua.LState) int { + key := ls.CheckString(1) + value := ls.CheckString(2) + if p.kv == nil { + ls.RaiseError("entire.kv is unavailable (no data dir resolved for plugin %q)", p.Manifest.Name) + return 0 + } + if err := p.kv.set(key, value); err != nil { + ls.RaiseError("entire.kv.set(%q): %v", key, err) + } + return 0 +} + +// luaKVDelete implements entire.kv.delete(key). +func (p *LoadedPlugin) luaKVDelete(ls *lua.LState) int { + key := ls.CheckString(1) + if p.kv == nil { + return 0 + } + if err := p.kv.del(key); err != nil { + ls.RaiseError("entire.kv.delete(%q): %v", key, err) + } + return 0 +} + +// log routes a plugin-authored message to the Entire log with the plugin's +// identity attached. The message text is carried as an attribute (not the log +// message) so log scraping stays keyed on the stable "lua plugin log" event. +func (p *LoadedPlugin) log(level slog.Level, msg string) { + ctx := p.dispatchCtx + if ctx == nil { + ctx = context.Background() + } + ctx = logging.WithComponent(ctx, "plugins") + attrs := []any{ + slog.String("plugin", p.Manifest.Name), + slog.String("source", string(p.Source)), + slog.String("message", msg), + } + switch level { + case slog.LevelDebug: + logging.Debug(ctx, "lua plugin log", attrs...) + case slog.LevelWarn: + logging.Warn(ctx, "lua plugin log", attrs...) + case slog.LevelError: + logging.Error(ctx, "lua plugin log", attrs...) + case slog.LevelInfo: + logging.Info(ctx, "lua plugin log", attrs...) + default: + logging.Info(ctx, "lua plugin log", attrs...) + } +} diff --git a/cmd/entire/cli/plugins/capabilities.go b/cmd/entire/cli/plugins/capabilities.go new file mode 100644 index 0000000000..69d80ece06 --- /dev/null +++ b/cmd/entire/cli/plugins/capabilities.go @@ -0,0 +1,274 @@ +package plugins + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + lua "github.com/yuin/gopher-lua" +) + +// installCapabilityAPI registers the privileged API surfaces (http, exec, fs, +// net) on the entire module. Every entry is capability-gated: calling one +// without the matching grant raises a Lua error naming the missing capability +// (fail loud, never silently no-op). Once granted, the API performs its action +// bounded by a timeout and, for fs, confined to the repo root and plugin data +// dir. +// +// The allow-list is the trust boundary: a plugin only reaches these APIs after +// the user explicitly enabled it AND granted the capability. The sandbox limits +// accidental damage and blast radius; it does not claim to fully contain a +// deliberately malicious allow-listed plugin. +func (p *LoadedPlugin) installCapabilityAPI(ls *lua.LState, mod *lua.LTable) { + httpTbl := ls.NewTable() + ls.SetField(httpTbl, "get", ls.NewFunction(p.luaHTTPGet)) + ls.SetField(httpTbl, "post", ls.NewFunction(p.luaHTTPPost)) + ls.SetField(mod, "http", httpTbl) + + execTbl := ls.NewTable() + ls.SetField(execTbl, "run", ls.NewFunction(p.luaExecRun)) + ls.SetField(mod, "exec", execTbl) + + fsTbl := ls.NewTable() + ls.SetField(fsTbl, "read", ls.NewFunction(p.luaFSRead)) + ls.SetField(fsTbl, "write", ls.NewFunction(p.luaFSWrite)) + ls.SetField(mod, "fs", fsTbl) + + // net is a coarse raw-network capability. entire.http is the supported + // network path; net.connect is reserved and gated but not implemented, so a + // plugin granting net still cannot open raw sockets in this build. + netTbl := ls.NewTable() + ls.SetField(netTbl, "connect", ls.NewFunction(func(l *lua.LState) int { + if !p.requireCapability(l, settings.PluginCapabilityNet, "entire.net.connect") { + return 0 + } + l.RaiseError("entire.net.connect is not implemented; use entire.http") + return 0 + })) + ls.SetField(mod, "net", netTbl) +} + +// requireCapability raises a Lua error and returns false when the plugin lacks +// capName. The error names the capability and how to grant it. +func (p *LoadedPlugin) requireCapability(ls *lua.LState, capName, apiName string) bool { + if p.Grant.HasCapability(capName) { + return true + } + ls.RaiseError("%s requires the %q capability, which plugin %q was not granted (add it to plugins.%s.capabilities in settings)", + apiName, capName, p.Manifest.Name, p.Manifest.Name) + return false +} + +// capabilityContext derives a timeout-bounded context from the in-flight +// dispatch context so a capability call is bounded even though a blocking Go +// call inside an LGFunction is not interrupted by the Lua state's own context. +func (p *LoadedPlugin) capabilityContext(timeout capTimeout) (context.Context, context.CancelFunc) { + parent := p.dispatchCtx + if parent == nil { + parent = context.Background() + } + return context.WithTimeout(parent, timeout.dur()) +} + +// capTimeout is a named duration to keep call sites self-documenting. +type capTimeout time.Duration + +func (c capTimeout) dur() time.Duration { return time.Duration(c) } + +// --- http --- + +func (p *LoadedPlugin) luaHTTPGet(ls *lua.LState) int { + if !p.requireCapability(ls, settings.PluginCapabilityHTTP, "entire.http.get") { + return 0 + } + url := ls.CheckString(1) + return p.doHTTP(ls, http.MethodGet, url, "", "") +} + +func (p *LoadedPlugin) luaHTTPPost(ls *lua.LState) int { + if !p.requireCapability(ls, settings.PluginCapabilityHTTP, "entire.http.post") { + return 0 + } + url := ls.CheckString(1) + body := ls.OptString(2, "") + contentType := ls.OptString(3, "application/json") + return p.doHTTP(ls, http.MethodPost, url, body, contentType) +} + +// doHTTP performs an HTTP request bounded by httpCapTimeout, limits the response +// body to httpMaxResponseBytes, and returns a Lua table {status, body}. +func (p *LoadedPlugin) doHTTP(ls *lua.LState, method, rawURL, body, contentType string) int { + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + ls.RaiseError("entire.http: only http/https URLs are allowed, got %q", rawURL) + return 0 + } + ctx, cancel := p.capabilityContext(capTimeout(httpCapTimeout)) + defer cancel() + + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, reader) + if err != nil { + ls.RaiseError("entire.http: build request: %v", err) + return 0 + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + client := &http.Client{Timeout: httpCapTimeout} + resp, err := client.Do(req) + if err != nil { + ls.RaiseError("entire.http: request failed: %v", err) + return 0 + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponseBytes)) + if err != nil { + ls.RaiseError("entire.http: read response: %v", err) + return 0 + } + + tbl := ls.NewTable() + ls.SetField(tbl, "status", lua.LNumber(resp.StatusCode)) + ls.SetField(tbl, "body", lua.LString(string(data))) + ls.Push(tbl) + return 1 +} + +// --- exec --- + +func (p *LoadedPlugin) luaExecRun(ls *lua.LState) int { + if !p.requireCapability(ls, settings.PluginCapabilityExec, "entire.exec.run") { + return 0 + } + name := ls.CheckString(1) + args := make([]string, 0, ls.GetTop()-1) + for i := 2; i <= ls.GetTop(); i++ { + args = append(args, ls.CheckString(i)) + } + + ctx, cancel := p.capabilityContext(capTimeout(execCapTimeout)) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + if p.WorktreeRoot != "" { + cmd.Dir = p.WorktreeRoot + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + code := 0 + if runErr := cmd.Run(); runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + code = exitErr.ExitCode() + } else { + ls.RaiseError("entire.exec.run(%q): %v", name, runErr) + return 0 + } + } + + tbl := ls.NewTable() + ls.SetField(tbl, "stdout", lua.LString(stdout.String())) + ls.SetField(tbl, "stderr", lua.LString(stderr.String())) + ls.SetField(tbl, "code", lua.LNumber(code)) + ls.Push(tbl) + return 1 +} + +// --- fs --- + +func (p *LoadedPlugin) luaFSRead(ls *lua.LState) int { + if !p.requireCapability(ls, settings.PluginCapabilityFS, "entire.fs.read") { + return 0 + } + abs, err := p.resolveFSPath(ls.CheckString(1)) + if err != nil { + ls.RaiseError("entire.fs.read: %v", err) + return 0 + } + data, err := os.ReadFile(abs) //nolint:gosec // path confined to repo root or plugin data dir by resolveFSPath + if err != nil { + ls.RaiseError("entire.fs.read: %v", err) + return 0 + } + if len(data) > fsMaxReadBytes { + ls.RaiseError("entire.fs.read: file exceeds %d bytes", fsMaxReadBytes) + return 0 + } + ls.Push(lua.LString(string(data))) + return 1 +} + +func (p *LoadedPlugin) luaFSWrite(ls *lua.LState) int { + if !p.requireCapability(ls, settings.PluginCapabilityFS, "entire.fs.write") { + return 0 + } + abs, err := p.resolveFSPath(ls.CheckString(1)) + if err != nil { + ls.RaiseError("entire.fs.write: %v", err) + return 0 + } + contents := ls.CheckString(2) + if err := os.MkdirAll(filepath.Dir(abs), 0o750); err != nil { + ls.RaiseError("entire.fs.write: %v", err) + return 0 + } + if err := os.WriteFile(abs, []byte(contents), 0o600); err != nil { + ls.RaiseError("entire.fs.write: %v", err) + return 0 + } + return 0 +} + +// resolveFSPath confines a plugin-supplied path to the repo root or the +// plugin's data dir, rejecting traversal outside both. Relative paths resolve +// against the repo root when known, else the data dir. +func (p *LoadedPlugin) resolveFSPath(raw string) (string, error) { + if strings.TrimSpace(raw) == "" { + return "", errors.New("empty path") + } + dataDir := "" + if p.kv != nil { + dataDir = p.kv.dir + } + + if filepath.IsAbs(raw) { + abs := filepath.Clean(raw) + if p.WorktreeRoot != "" && paths.IsSubpath(p.WorktreeRoot, abs) { + return abs, nil + } + if dataDir != "" && paths.IsSubpath(dataDir, abs) { + return abs, nil + } + return "", fmt.Errorf("path %q is outside the repo root and plugin data dir", raw) + } + + base := p.WorktreeRoot + if base == "" { + base = dataDir + } + if base == "" { + return "", errors.New("no repo root or data dir to resolve a relative path") + } + abs := filepath.Clean(filepath.Join(base, raw)) + if !paths.IsSubpath(base, abs) { + return "", fmt.Errorf("path %q escapes %s", raw, base) + } + return abs, nil +} diff --git a/cmd/entire/cli/plugins/capabilities_test.go b/cmd/entire/cli/plugins/capabilities_test.go new file mode 100644 index 0000000000..43b32fd6cc --- /dev/null +++ b/cmd/entire/cli/plugins/capabilities_test.go @@ -0,0 +1,195 @@ +package plugins + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/settings" + lua "github.com/yuin/gopher-lua" +) + +// luaFalse is the string form of a Lua false boolean, as returned by LValue.String(). +const luaFalse = "false" + +// loadAndFire loads a plugin from an inline main.lua with the given grant, fires +// turn_end once, and returns the plugin for global inspection. +func loadAndFire(t *testing.T, mainLua string, grant settings.PluginSettings, worktreeRoot string) *LoadedPlugin { + t.Helper() + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + dir := writePluginDir(t, `{"name":"cap","hooks":["turn_end"]}`, mainLua) + p, err := LoadPlugin(context.Background(), dir, SourceUser, worktreeRoot, grant) + if err != nil { + t.Fatalf("LoadPlugin() error = %v", err) + } + reg := &Registry{} + reg.Add(p) + t.Cleanup(reg.Close) + reg.FireObserver(context.Background(), HookTurnEnd, nil) + return p +} + +func TestCapability_DeniedWithoutGrant(t *testing.T) { + const probe = ` +entire.on("turn_end", function() + local ok, err = pcall(function() entire.exec.run("echo", "hi") end) + _G.ok = ok + _G.err = tostring(err) +end) +` + p := loadAndFire(t, probe, settings.PluginSettings{Enabled: true}, "") + if p.L.GetGlobal("ok").String() != luaFalse { + t.Error("expected exec.run to be denied without the exec grant") + } + if errMsg := p.L.GetGlobal("err").String(); !strings.Contains(errMsg, "capability") { + t.Errorf("expected capability-denied error, got %q", errMsg) + } +} + +func TestCapability_ExecRunsWhenGranted(t *testing.T) { + if runtime.GOOS == windowsGOOS { + t.Skip("echo semantics differ on Windows") + } + const probe = ` +entire.on("turn_end", function() + local r = entire.exec.run("echo", "hi") + _G.out = r.stdout + _G.code = r.code +end) +` + grant := settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityExec}} + p := loadAndFire(t, probe, grant, "") + if got := strings.TrimSpace(p.L.GetGlobal("out").String()); got != "hi" { + t.Errorf("exec stdout = %q, want hi", got) + } + if got := lua.LVAsNumber(p.L.GetGlobal("code")); got != 0 { + t.Errorf("exec code = %v, want 0", got) + } +} + +func TestCapability_FSReadWriteConfined(t *testing.T) { + root := t.TempDir() + const probe = ` +entire.on("turn_end", function() + entire.fs.write("sub/x.txt", "hello") + _G.content = entire.fs.read("sub/x.txt") + local ok = pcall(function() entire.fs.read("../escape.txt") end) + _G.escape_ok = ok +end) +` + grant := settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityFS}} + p := loadAndFire(t, probe, grant, root) + + if got := p.L.GetGlobal("content").String(); got != "hello" { + t.Errorf("fs read = %q, want hello", got) + } + if got := filepath.Join(root, "sub", "x.txt"); !fileHasContent(t, got, "hello") { + t.Errorf("expected file written under repo root at %s", got) + } + if p.L.GetGlobal("escape_ok").String() != luaFalse { + t.Error("expected traversal outside repo root to be rejected") + } +} + +func TestCapability_HTTPGetWhenGranted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + fmt.Fprint(w, "brewing") + })) + defer srv.Close() + + probe := fmt.Sprintf(` +entire.on("turn_end", function() + local r = entire.http.get(%q) + _G.status = r.status + _G.body = r.body + local ok = pcall(function() entire.http.get("file:///etc/passwd") end) + _G.scheme_ok = ok +end) +`, srv.URL) + + grant := settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityHTTP}} + p := loadAndFire(t, probe, grant, "") + + if got := lua.LVAsNumber(p.L.GetGlobal("status")); got != http.StatusTeapot { + t.Errorf("http status = %v, want 418", got) + } + if got := p.L.GetGlobal("body").String(); got != "brewing" { + t.Errorf("http body = %q", got) + } + if p.L.GetGlobal("scheme_ok").String() != luaFalse { + t.Error("expected non-http(s) scheme to be rejected") + } +} + +func TestCapability_NetConnectGatedNotImplemented(t *testing.T) { + const probe = ` +entire.on("turn_end", function() + local ok, err = pcall(function() entire.net.connect("host", 80) end) + _G.ok = ok + _G.err = tostring(err) +end) +` + grant := settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityNet}} + p := loadAndFire(t, probe, grant, "") + if p.L.GetGlobal("ok").String() != luaFalse { + t.Error("expected net.connect to error") + } + if errMsg := p.L.GetGlobal("err").String(); !strings.Contains(errMsg, "not implemented") { + t.Errorf("expected not-implemented error, got %q", errMsg) + } +} + +func TestReadOnlyAccessors(t *testing.T) { + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + main := ` +_G.name = entire.plugin_name +_G.src = entire.source +_G.root = entire.repo_root +` + dir := writePluginDir(t, `{"name":"accessors","version":"2.3.4"}`, main) + p, err := LoadPlugin(context.Background(), dir, SourceRepo, "/tmp/repo", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin() error = %v", err) + } + defer p.Close() + + if got := p.L.GetGlobal("name").String(); got != "accessors" { + t.Errorf("plugin_name = %q", got) + } + if got := p.L.GetGlobal("src").String(); got != "repo" { + t.Errorf("source = %q", got) + } + if got := p.L.GetGlobal("root").String(); got != "/tmp/repo" { + t.Errorf("repo_root = %q", got) + } +} + +func TestDiscover_KillSwitchDisablesAll(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + t.Setenv(pluginsDisabledEnv, "1") + writeUserPlugin(t, parent, "notify", `{"name":"notify"}`, turnEndCounter) + + s := &settings.EntireSettings{Plugins: map[string]settings.PluginSettings{"notify": {Enabled: true}}} + reg := Discover(context.Background(), "", s, nil) + defer reg.Close() + if reg.Len() != 0 { + t.Fatalf("kill switch should disable all plugins, got %d", reg.Len()) + } +} + +func fileHasContent(t *testing.T, path, want string) bool { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + return false + } + return string(data) == want +} diff --git a/cmd/entire/cli/plugins/command.go b/cmd/entire/cli/plugins/command.go new file mode 100644 index 0000000000..5f46372d9a --- /dev/null +++ b/cmd/entire/cli/plugins/command.go @@ -0,0 +1,177 @@ +package plugins + +import ( + "context" + "fmt" + "log/slog" + "os" + "sort" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/logging" + lua "github.com/yuin/gopher-lua" +) + +// commandEntry is a CLI subcommand contributed by a plugin via entire.command. +type commandEntry struct { + name string + short string + plugin string + run *lua.LFunction +} + +// luaCommand implements entire.command{name=..., short=..., run=function(args) ... end}. +// It registers a subcommand invocable as `entire `. The run callback +// receives a Lua array of the remaining CLI args and may return an integer exit +// code (nil/absent means 0). +func (p *LoadedPlugin) luaCommand(ls *lua.LState) int { + tbl := ls.CheckTable(1) + + nameV := tbl.RawGetString("name") + name, ok := nameV.(lua.LString) + if !ok || string(name) == "" { + ls.ArgError(1, "entire.command requires a string 'name'") + return 0 + } + if err := ValidatePluginName(string(name)); err != nil { + ls.ArgError(1, fmt.Sprintf("entire.command name: %v", err)) + return 0 + } + + runV := tbl.RawGetString("run") + run, ok := runV.(*lua.LFunction) + if !ok { + ls.ArgError(1, "entire.command requires a 'run' function") + return 0 + } + + short := "" + if s, ok := tbl.RawGetString("short").(lua.LString); ok { + short = string(s) + } + + p.commands[string(name)] = &commandEntry{ + name: string(name), + short: short, + plugin: p.Manifest.Name, + run: run, + } + return 0 +} + +// CommandInfo describes a plugin-contributed command for listing/help. +type CommandInfo struct { + Name string + Short string + Plugin string +} + +// Commands returns all plugin-contributed commands across the registry, sorted +// by name. When two plugins contribute the same name, the first in load order +// wins (matching RunCommand's resolution). +func (r *Registry) Commands() []CommandInfo { + if r == nil { + return nil + } + seen := make(map[string]struct{}) + var out []CommandInfo + for _, p := range r.plugins { + for name, ce := range p.commands { + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + out = append(out, CommandInfo{Name: name, Short: ce.short, Plugin: ce.plugin}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// FindCommand reports whether any plugin contributes a command with the given +// name (first in load order wins). +func (r *Registry) FindCommand(name string) (CommandInfo, bool) { + if r == nil { + return CommandInfo{}, false + } + for _, p := range r.plugins { + if ce, ok := p.commands[name]; ok { + return CommandInfo{Name: ce.name, Short: ce.short, Plugin: ce.plugin}, true + } + } + return CommandInfo{}, false +} + +// RunCommand runs the plugin command matching name with the given args and +// returns its exit code and whether a command was found. The command runs under +// the supplied context (cancellable via signal), not a short hook timeout, so +// interactive/long-running commands work. The first plugin (in load order) +// contributing the name wins. +func (r *Registry) RunCommand(ctx context.Context, name string, args []string) (exitCode int, found bool) { + if r == nil { + return 0, false + } + r.mu.Lock() + defer r.mu.Unlock() + for _, p := range r.plugins { + ce, ok := p.commands[name] + if !ok { + continue + } + return r.runCommand(ctx, p, ce, args), true + } + return 0, false +} + +func (r *Registry) runCommand(ctx context.Context, p *LoadedPlugin, ce *commandEntry, args []string) (code int) { + logCtx := logging.WithComponent(ctx, "plugins") + p.dispatchCtx = ctx + p.L.SetContext(ctx) + defer p.L.SetContext(context.Background()) + defer func() { + if rec := recover(); rec != nil { + fmt.Fprintf(os.Stderr, "entire %s: plugin %q panicked\n", ce.name, p.Manifest.Name) + code = 1 + } + }() + + argTbl := p.L.NewTable() + for _, a := range args { + argTbl.Append(lua.LString(a)) + } + if err := p.L.CallByParam(lua.P{Fn: ce.run, NRet: 1, Protect: true}, argTbl); err != nil { + logging.Debug(logCtx, "plugin command failed", + slog.String("plugin", p.Manifest.Name), slog.String("command", ce.name), slog.String("error", err.Error())) + fmt.Fprintf(os.Stderr, "entire %s: %v\n", ce.name, err) + return 1 + } + ret := p.L.Get(-1) + p.L.Pop(1) + if n, ok := ret.(lua.LNumber); ok { + return int(n) + } + return 0 +} + +// luaStdoutPrint returns an entire.print/entire.write binding that writes to the +// process stdout (for plugin commands' user-facing output). withNewline appends +// a trailing newline (print) vs not (write). Unlike the log-routed global +// print(), this reaches real stdout, so plugins should use it only in commands, +// not in hooks (where it could corrupt hook stdout). +func luaStdoutPrint(withNewline bool) lua.LGFunction { + return func(ls *lua.LState) int { + var sb strings.Builder + top := ls.GetTop() + for i := 1; i <= top; i++ { + if i > 1 { + sb.WriteByte('\t') + } + sb.WriteString(ls.ToStringMeta(ls.Get(i)).String()) + } + if withNewline { + sb.WriteByte('\n') + } + fmt.Fprint(os.Stdout, sb.String()) + return 0 + } +} diff --git a/cmd/entire/cli/plugins/command_test.go b/cmd/entire/cli/plugins/command_test.go new file mode 100644 index 0000000000..65a6906945 --- /dev/null +++ b/cmd/entire/cli/plugins/command_test.go @@ -0,0 +1,107 @@ +package plugins + +import ( + "context" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +func TestRunCommand_ExecutesAndReturnsCode(t *testing.T) { + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + dir := writePluginDir(t, + `{"name":"greeter","commands":[{"name":"greet","short":"say hi"}]}`, + ` +entire.command{ + name = "greet", + short = "say hi", + run = function(args) + _G.arg1 = args[1] + _G.argc = #args + return 7 + end +} +`) + p, err := LoadPlugin(context.Background(), dir, SourceUser, "", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin() error = %v", err) + } + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + code, found := reg.RunCommand(context.Background(), "greet", []string{"world", "again"}) + if !found { + t.Fatal("expected command to be found") + } + if code != 7 { + t.Errorf("exit code = %d, want 7", code) + } + if got := p.L.GetGlobal("arg1").String(); got != "world" { + t.Errorf("arg1 = %q, want world", got) + } +} + +func TestRunCommand_NotFound(t *testing.T) { + reg := &Registry{} + if _, found := reg.RunCommand(context.Background(), "nope", nil); found { + t.Fatal("expected not found for unknown command") + } +} + +func TestRunCommand_DefaultsToZeroExit(t *testing.T) { + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + dir := writePluginDir(t, `{"name":"p"}`, + `entire.command{name="noop", run=function() end}`) + p, err := LoadPlugin(context.Background(), dir, SourceUser, "", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin() error = %v", err) + } + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + code, found := reg.RunCommand(context.Background(), "noop", nil) + if !found || code != 0 { + t.Fatalf("RunCommand noop = (%d, %v), want (0, true)", code, found) + } +} + +func TestRunCommand_ErrorReturnsExitOne(t *testing.T) { + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + dir := writePluginDir(t, `{"name":"p"}`, + `entire.command{name="boom", run=function() error("kaboom") end}`) + p, err := LoadPlugin(context.Background(), dir, SourceUser, "", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin() error = %v", err) + } + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + code, found := reg.RunCommand(context.Background(), "boom", nil) + if !found || code != 1 { + t.Fatalf("RunCommand boom = (%d, %v), want (1, true)", code, found) + } +} + +func TestCommands_ListSortedFirstWins(t *testing.T) { + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + dirA := writePluginDir(t, `{"name":"a"}`, `entire.command{name="zeta", run=function() end} +entire.command{name="alpha", run=function() end}`) + pa, err := LoadPlugin(context.Background(), dirA, SourceUser, "", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin(a) error = %v", err) + } + reg := &Registry{} + reg.Add(pa) + defer reg.Close() + + cmds := reg.Commands() + if len(cmds) != 2 || cmds[0].Name != "alpha" || cmds[1].Name != "zeta" { + t.Fatalf("Commands() = %+v, want [alpha, zeta] sorted", cmds) + } + if info, ok := reg.FindCommand("alpha"); !ok || info.Plugin != "a" { + t.Errorf("FindCommand(alpha) = %+v, %v", info, ok) + } +} diff --git a/cmd/entire/cli/plugins/config.go b/cmd/entire/cli/plugins/config.go new file mode 100644 index 0000000000..181695577d --- /dev/null +++ b/cmd/entire/cli/plugins/config.go @@ -0,0 +1,53 @@ +package plugins + +import ( + "os" + "strconv" + "time" +) + +// Timeouts bounding plugin execution. Kept small: plugins are event callbacks +// on the agent's critical path (a git commit, a turn boundary), so a slow or +// runaway plugin must never stall the host. They are enforced via the Lua +// state's context, which aborts a running script between VM instructions. +const ( + // loadTimeout bounds the one-time entry-script execution at load. The entry + // only registers hooks/commands, so this is generous headroom. + loadTimeout = 5 * time.Second + + // observerHookTimeout bounds a single observer callback invocation. Can be + // raised via ENTIRE_PLUGIN_HOOK_TIMEOUT_MS for plugins that make slower + // capability calls (http/exec). + observerHookTimeout = 2 * time.Second + + // Capability call limits. These bound the blast radius of the privileged + // APIs even for an allow-listed plugin. + httpCapTimeout = 10 * time.Second + httpMaxResponseBytes = 5 << 20 // 5 MiB + execCapTimeout = 30 * time.Second + fsMaxReadBytes = 10 << 20 // 10 MiB +) + +// hookTimeoutEnv overrides observerHookTimeout when set to a positive integer +// number of milliseconds. +const hookTimeoutEnv = "ENTIRE_PLUGIN_HOOK_TIMEOUT_MS" + +// pluginsDisabledEnv is a process-wide kill switch: when set to 1/true, no +// plugin is loaded regardless of the allow-list. +const pluginsDisabledEnv = "ENTIRE_PLUGINS_DISABLED" + +// hookTimeout returns the effective per-hook timeout, honoring the env override. +func hookTimeout() time.Duration { + if v := os.Getenv(hookTimeoutEnv); v != "" { + if ms, err := strconv.Atoi(v); err == nil && ms > 0 { + return time.Duration(ms) * time.Millisecond + } + } + return observerHookTimeout +} + +// pluginsDisabledByEnv reports whether the kill switch is set. +func pluginsDisabledByEnv() bool { + v := os.Getenv(pluginsDisabledEnv) + return v == "1" || v == "true" +} diff --git a/cmd/entire/cli/plugins/dispatch.go b/cmd/entire/cli/plugins/dispatch.go new file mode 100644 index 0000000000..6a9f13773a --- /dev/null +++ b/cmd/entire/cli/plugins/dispatch.go @@ -0,0 +1,107 @@ +package plugins + +import ( + "context" + "sync" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// Process-wide plugin registry. Entire hook invocations are short-lived, +// single-purpose processes (one git hook or one lifecycle event per exec), so a +// lazily-built per-process registry is the right lifetime: it is constructed on +// the first FireHook call and reused for any subsequent fires in the same +// process. Package-level state is acceptable here because the registry models a +// process-global resource; access is guarded by globalMu. +var ( + globalMu sync.Mutex + globalReg *Registry + globalInit bool +) + +// FireHook dispatches an observer hook to every enabled plugin. It lazily +// builds the process registry on first use from settings and the current +// worktree root. When no plugin is enabled it is a fast no-op, so seams can +// call it unconditionally without measurable cost in the common case. +// +// Firing is best-effort: plugin failures are logged and never propagated, so a +// misbehaving observer cannot break a lifecycle event or git hook. +func FireHook(ctx context.Context, hook string, payload map[string]any) { + globalRegistry(ctx).FireObserver(ctx, hook, payload) +} + +// Enabled reports whether any plugin is loaded in this process. Seams can use +// it to skip building an expensive payload when there are no subscribers. +func Enabled(ctx context.Context) bool { + return globalRegistry(ctx).Len() > 0 +} + +// FireCommitMsg dispatches the prepare_commit_msg mutating hook and returns the +// trailer lines contributed by capable plugins (empty when none). See +// Registry.FireCommitMsg for semantics. +func FireCommitMsg(ctx context.Context, payload map[string]any) []string { + return globalRegistry(ctx).FireCommitMsg(ctx, payload) +} + +// FirePrePush dispatches the pre_push hook (observer + veto). Returns a non-nil +// error when a capable plugin vetoes the push. See Registry.FirePrePush. +func FirePrePush(ctx context.Context, payload map[string]any) error { + return globalRegistry(ctx).FirePrePush(ctx, payload) +} + +// RunCommand runs a plugin-contributed command by name, returning its exit code +// and whether a command was found. See Registry.RunCommand. +func RunCommand(ctx context.Context, name string, args []string) (exitCode int, found bool) { + return globalRegistry(ctx).RunCommand(ctx, name, args) +} + +// ProcessCommands returns the plugin-contributed commands available in this +// process, for listing/help. +func ProcessCommands(ctx context.Context) []CommandInfo { + return globalRegistry(ctx).Commands() +} + +func globalRegistry(ctx context.Context) *Registry { + globalMu.Lock() + defer globalMu.Unlock() + if globalInit { + return globalReg + } + globalInit = true + globalReg = buildProcessRegistry(ctx) + return globalReg +} + +// buildProcessRegistry loads settings and discovers plugins for the current +// worktree. Any failure yields an empty (no-op) registry rather than an error: +// plugins are an optional, best-effort layer. +func buildProcessRegistry(ctx context.Context) *Registry { + s, err := settings.Load(ctx) + if err != nil || !hasEnabledPlugin(s) { + return &Registry{} + } + worktreeRoot, _ := paths.WorktreeRoot(ctx) //nolint:errcheck // outside a repo only user plugins are considered; empty root is fine + // localGrants comes from .entire/settings.local.json only; it is the sole + // authority for enabling repo-local plugins so a committed team + // settings.json cannot auto-run repo-shipped plugin code. On error we fail + // closed (nil grants → repo-local plugins stay inert); user-global plugins + // still resolve from the merged settings s. + localGrants, lgErr := settings.LocalPluginGrants(ctx) + if lgErr != nil { + localGrants = nil + } + return Discover(ctx, worktreeRoot, s, localGrants) +} + +// ResetProcessRegistryForTest clears the process registry so a test can rebuild +// it. Not for production use. +func ResetProcessRegistryForTest() { + globalMu.Lock() + defer globalMu.Unlock() + if globalReg != nil { + globalReg.Close() + } + globalReg = nil + globalInit = false +} diff --git a/cmd/entire/cli/plugins/examples_test.go b/cmd/entire/cli/plugins/examples_test.go new file mode 100644 index 0000000000..d435fda8d6 --- /dev/null +++ b/cmd/entire/cli/plugins/examples_test.go @@ -0,0 +1,66 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// examplesDir resolves the repo-root examples/plugins dir relative to this +// package's working directory (cmd/entire/cli/plugins) at test time. +func examplesDir(t *testing.T) string { + t.Helper() + dir := filepath.Join("..", "..", "..", "..", "examples", "plugins") + if _, err := os.Stat(dir); err != nil { + t.Skipf("examples dir not found: %v", err) + } + return dir +} + +func TestExamplePlugins_LoadCleanly(t *testing.T) { + base := examplesDir(t) + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + + cases := []struct { + name string + grant settings.PluginSettings + wantHook string + wantCmd string + }{ + { + name: "checkpoint-notify", + grant: settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityExec}}, + wantHook: HookCheckpointSaved, + wantCmd: "notify-stats", + }, + { + name: "models-updater", + grant: settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityHTTP, settings.PluginCapabilityFS}}, + wantHook: HookSessionStart, + wantCmd: "models-update", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(base, tc.name) + p, err := LoadPlugin(context.Background(), dir, SourceUser, "", tc.grant) + if err != nil { + t.Fatalf("LoadPlugin(%s) error = %v", tc.name, err) + } + defer p.Close() + + if tc.wantHook != "" && !p.HasHook(tc.wantHook) { + t.Errorf("%s did not register hook %q", tc.name, tc.wantHook) + } + if tc.wantCmd != "" { + if _, ok := p.commands[tc.wantCmd]; !ok { + t.Errorf("%s did not register command %q", tc.name, tc.wantCmd) + } + } + }) + } +} diff --git a/cmd/entire/cli/plugins/hooks.go b/cmd/entire/cli/plugins/hooks.go new file mode 100644 index 0000000000..320494d68b --- /dev/null +++ b/cmd/entire/cli/plugins/hooks.go @@ -0,0 +1,85 @@ +package plugins + +// Hook names subscribable via entire.on. These are the stable, documented +// identifiers third-party plugins bind to; they are decoupled from the internal +// agent.EventType enum so the enum can evolve without breaking plugins. +// +// Observer hooks run for side effects only and cannot change CLI behavior; a +// failing observer is logged and ignored. Mutating hooks (prepare_commit_msg, +// pre_push) can influence the outcome and are gated behind capabilities and +// explicit ordering — see docs/architecture/plugins-lua.md. +const ( + // HookSessionStart fires when an agent session begins. + HookSessionStart = "session_start" + // HookTurnStart fires when the user submits a prompt (turn begins). + HookTurnStart = "turn_start" + // HookTurnEnd fires after the agent finishes responding to a prompt. + HookTurnEnd = "turn_end" + // HookCheckpointSaved fires after a checkpoint step is written. + HookCheckpointSaved = "checkpoint_saved" + // HookPostCommit fires after a git commit that carries a checkpoint. + HookPostCommit = "post_commit" + // HookPrePush fires before a git push (observer variant). + HookPrePush = "pre_push" + // HookSubagentEnd fires after a subagent/task completes. + HookSubagentEnd = "subagent_end" + // HookSessionEnd fires when a session ends. + HookSessionEnd = "session_end" + // HookCompaction fires when the agent compacts its context window. + HookCompaction = "compaction" + // HookModelUpdate fires when the agent reports the active model. + HookModelUpdate = "model_update" + + // HookPrepareCommitMsg is a mutating hook: callbacks may return a trailer + // string appended to the commit message. Capability/ordering gated. + HookPrepareCommitMsg = "prepare_commit_msg" +) + +// observerHooks is the set of hooks that run for side effects only. +var observerHooks = map[string]struct{}{ + HookSessionStart: {}, + HookTurnStart: {}, + HookTurnEnd: {}, + HookCheckpointSaved: {}, + HookPostCommit: {}, + HookPrePush: {}, + HookSubagentEnd: {}, + HookSessionEnd: {}, + HookCompaction: {}, + HookModelUpdate: {}, +} + +// mutatingHooks is the set of hooks whose callbacks can influence CLI behavior. +var mutatingHooks = map[string]struct{}{ + HookPrepareCommitMsg: {}, + // pre_push additionally supports a mutating (veto) variant; it is listed in + // observerHooks for its observer semantics and treated as vetoable at the + // mutating call site (see phase 4). +} + +// IsKnownHook reports whether name is a hook plugins may subscribe to. +func IsKnownHook(name string) bool { + if _, ok := observerHooks[name]; ok { + return true + } + _, ok := mutatingHooks[name] + return ok +} + +// IsObserverHook reports whether name is an observer-only hook. +func IsObserverHook(name string) bool { + _, ok := observerHooks[name] + return ok +} + +// KnownHooks returns all subscribable hook names (unordered). +func KnownHooks() []string { + out := make([]string, 0, len(observerHooks)+len(mutatingHooks)) + for h := range observerHooks { + out = append(out, h) + } + for h := range mutatingHooks { + out = append(out, h) + } + return out +} diff --git a/cmd/entire/cli/plugins/json.go b/cmd/entire/cli/plugins/json.go new file mode 100644 index 0000000000..2801566432 --- /dev/null +++ b/cmd/entire/cli/plugins/json.go @@ -0,0 +1,58 @@ +package plugins + +import ( + "encoding/json" + + lua "github.com/yuin/gopher-lua" +) + +// entire.json is a pure, deterministic JSON codec for plugin authors. Unlike +// http/exec/fs it reaches nothing outside the Lua state, so it is always +// available — never capability-gated. It is registered on the entire module in +// installAPI alongside entire.log / entire.kv / entire.print. +// +// The conversion in both directions reuses the shared value marshalers in +// marshal.go (toLuaValue / fromLuaValue), so decode and encode agree on how the +// JSON-ish shapes map to Lua: objects↔tables, arrays↔sequences, numbers↔Lua +// numbers, and true/false/null↔boolean/nil. + +// luaJSONDecode implements entire.json.decode(str) -> value. It parses str as +// JSON and returns the equivalent Lua value: a JSON object becomes a table +// keyed by its member names, an array becomes a 1..n sequence, numbers (plain, +// fractional, or scientific-notation such as 3e-06) become Lua numbers, and +// true/false/null become boolean/boolean/nil. Invalid JSON raises a Lua error. +func luaJSONDecode(ls *lua.LState) int { + s := ls.CheckString(1) + // Unmarshal into interface{}: encoding/json yields map[string]any, []any, + // float64, string, bool and nil — exactly the shapes toLuaValue converts. + // Numbers arrive as float64, which is Lua's only number type, so fractional + // and exponent values are preserved (3e-06 -> 0.000003) for free. + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + ls.RaiseError("entire.json.decode: %v", err) + return 0 + } + ls.Push(toLuaValue(ls, v)) + return 1 +} + +// luaJSONEncode implements entire.json.encode(value) -> string. It converts the +// Lua value to its Go equivalent (via fromLuaValue) and marshals it to a JSON +// string. A table is encoded as an array when it is a dense 1..n sequence and as +// an object otherwise (an empty table encodes as {}). Values with no JSON form +// (functions, userdata) and cyclic/over-deep tables raise a Lua error. +func luaJSONEncode(ls *lua.LState) int { + v := ls.CheckAny(1) + gov, err := fromLuaValue(v, 0) + if err != nil { + ls.RaiseError("entire.json.encode: %v", err) + return 0 + } + out, err := json.Marshal(gov) + if err != nil { + ls.RaiseError("entire.json.encode: %v", err) + return 0 + } + ls.Push(lua.LString(out)) + return 1 +} diff --git a/cmd/entire/cli/plugins/json_test.go b/cmd/entire/cli/plugins/json_test.go new file mode 100644 index 0000000000..9b870012c6 --- /dev/null +++ b/cmd/entire/cli/plugins/json_test.go @@ -0,0 +1,180 @@ +package plugins + +import ( + "context" + "math" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/settings" + lua "github.com/yuin/gopher-lua" +) + +// loadJSONProbe loads a plugin whose entry script exercises entire.json at load +// time and stores results in _G globals for inspection. It grants NO +// capabilities, so a successful load also proves entire.json is always available +// (never capability-gated). A raised Lua error during load fails the test. +func loadJSONProbe(t *testing.T, script string) *LoadedPlugin { + t.Helper() + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + dir := writePluginDir(t, `{"name":"jsonprobe"}`, script) + p, err := LoadPlugin(context.Background(), dir, SourceUser, "", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin() error = %v", err) + } + t.Cleanup(p.Close) + return p +} + +// TestJSON_DecodeEncodeRoundTrip drives entire.json.decode / encode across the +// JSON-ish shapes plugins actually see: objects, arrays, nesting, numbers +// (including scientific notation), booleans, null and strings, plus an +// encode→decode round trip. entire.json is granted no capability here. +func TestJSON_DecodeEncodeRoundTrip(t *testing.T) { + const probe = ` +-- decode: object with number / string / bool / null members +local obj = entire.json.decode('{"a": 1, "b": "two", "c": true, "d": null}') +_G.obj_a = obj.a +_G.obj_b = obj.b +_G.obj_c = obj.c +_G.obj_d_is_nil = (obj.d == nil) + +-- decode: array -> 1-based sequence +local arr = entire.json.decode('[10, 20, 30]') +_G.arr_len = #arr +_G.arr_1 = arr[1] +_G.arr_3 = arr[3] + +-- decode: nested object + scientific-notation number + nested array +local nested = entire.json.decode('{"model": {"input_cost_per_token": 3e-06, "tags": ["x", "y"]}}') +_G.nested_input = nested.model.input_cost_per_token +_G.nested_tag2 = nested.model.tags[2] + +-- decode: bare scalars +_G.scalar_num = entire.json.decode('42') +_G.scalar_true = entire.json.decode('true') +_G.scalar_str = entire.json.decode('"hi"') +_G.scalar_null_is_nil = (entire.json.decode('null') == nil) + +-- encode: object, then decode it back (round trip) +local enc = entire.json.encode({ name = "gpt", rate = 3e-06, on = true }) +_G.enc_str = enc +local back = entire.json.decode(enc) +_G.rt_name = back.name +_G.rt_rate = back.rate +_G.rt_on = back.on + +-- encode: dense sequence -> JSON array +_G.enc_arr = entire.json.encode({ 1, 2, 3 }) + +-- encode: empty table -> JSON object +_G.enc_empty = entire.json.encode({}) + +-- encode: nested, round-tripped +local back2 = entire.json.decode(entire.json.encode({ outer = { inner = { 7, 8 } } })) +_G.rt_nested = back2.outer.inner[2] +` + p := loadJSONProbe(t, probe) + L := p.L + + // --- decode object --- + if got := lua.LVAsNumber(L.GetGlobal("obj_a")); got != 1 { + t.Errorf("obj.a = %v, want 1", got) + } + if got := L.GetGlobal("obj_b").String(); got != "two" { + t.Errorf("obj.b = %q, want two", got) + } + if !lua.LVAsBool(L.GetGlobal("obj_c")) { + t.Error("obj.c should decode to true") + } + if !lua.LVAsBool(L.GetGlobal("obj_d_is_nil")) { + t.Error("obj.d should decode to nil (JSON null), got not-nil") + } + + // --- decode array --- + if got := lua.LVAsNumber(L.GetGlobal("arr_len")); got != 3 { + t.Errorf("#arr = %v, want 3", got) + } + if got := lua.LVAsNumber(L.GetGlobal("arr_1")); got != 10 { + t.Errorf("arr[1] = %v, want 10", got) + } + if got := lua.LVAsNumber(L.GetGlobal("arr_3")); got != 30 { + t.Errorf("arr[3] = %v, want 30", got) + } + + // --- decode nested + scientific notation --- + if got := float64(lua.LVAsNumber(L.GetGlobal("nested_input"))); math.Abs(got-3e-06) > 1e-18 { + t.Errorf("nested scientific-notation number = %v, want 3e-06", got) + } + if got := L.GetGlobal("nested_tag2").String(); got != "y" { + t.Errorf("nested.model.tags[2] = %q, want y", got) + } + + // --- decode scalars --- + if got := lua.LVAsNumber(L.GetGlobal("scalar_num")); got != 42 { + t.Errorf("decode('42') = %v, want 42", got) + } + if !lua.LVAsBool(L.GetGlobal("scalar_true")) { + t.Error("decode('true') should be boolean true") + } + if got := L.GetGlobal("scalar_str").String(); got != "hi" { + t.Errorf("decode('\"hi\"') = %q, want hi", got) + } + if !lua.LVAsBool(L.GetGlobal("scalar_null_is_nil")) { + t.Error("decode('null') should be nil") + } + + // --- encode round trip --- + if got := L.GetGlobal("rt_name").String(); got != "gpt" { + t.Errorf("round-tripped name = %q, want gpt", got) + } + if got := float64(lua.LVAsNumber(L.GetGlobal("rt_rate"))); math.Abs(got-3e-06) > 1e-18 { + t.Errorf("round-tripped rate = %v, want 3e-06", got) + } + if !lua.LVAsBool(L.GetGlobal("rt_on")) { + t.Error("round-tripped bool should be true") + } + if got := L.GetGlobal("enc_arr").String(); got != "[1,2,3]" { + t.Errorf("encode({1,2,3}) = %q, want [1,2,3]", got) + } + if got := L.GetGlobal("enc_empty").String(); got != "{}" { + t.Errorf("encode({}) = %q, want {} (empty table encodes as object)", got) + } + if got := lua.LVAsNumber(L.GetGlobal("rt_nested")); got != 8 { + t.Errorf("round-tripped nested value = %v, want 8", got) + } + // The encoded object must itself be valid JSON with the expected members. + if enc := L.GetGlobal("enc_str").String(); !strings.Contains(enc, `"name":"gpt"`) { + t.Errorf("encoded object %q missing name member", enc) + } +} + +// TestJSON_DecodeInvalidRaises confirms malformed JSON raises a Lua error (not a +// silent nil), so a plugin fails loud on garbage input. +func TestJSON_DecodeInvalidRaises(t *testing.T) { + const probe = ` +_G.ok, _G.err = pcall(function() return entire.json.decode('{not valid') end) +` + p := loadJSONProbe(t, probe) + if lua.LVAsBool(p.L.GetGlobal("ok")) { + t.Error("decode of invalid JSON should raise (pcall ok = true)") + } + if errMsg := p.L.GetGlobal("err").String(); !strings.Contains(errMsg, "entire.json.decode") { + t.Errorf("expected decode error to name entire.json.decode, got %q", errMsg) + } +} + +// TestJSON_EncodeRejectsUnsupported confirms a value with no JSON form (a +// function) raises rather than producing bogus output. +func TestJSON_EncodeRejectsUnsupported(t *testing.T) { + const probe = ` +_G.ok, _G.err = pcall(function() return entire.json.encode(function() end) end) +` + p := loadJSONProbe(t, probe) + if lua.LVAsBool(p.L.GetGlobal("ok")) { + t.Error("encode of a function should raise (pcall ok = true)") + } + if errMsg := p.L.GetGlobal("err").String(); !strings.Contains(errMsg, "entire.json.encode") { + t.Errorf("expected encode error to name entire.json.encode, got %q", errMsg) + } +} diff --git a/cmd/entire/cli/plugins/kv.go b/cmd/entire/cli/plugins/kv.go new file mode 100644 index 0000000000..160dfb1136 --- /dev/null +++ b/cmd/entire/cli/plugins/kv.go @@ -0,0 +1,100 @@ +package plugins + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// kvStore is a tiny string→string key/value store backed by a single JSON file +// in the plugin's data dir (entire.kv). It is loaded lazily on first access and +// written through on every set. It is only ever touched under the Registry +// mutex (via the plugin's Lua state), so it needs no internal locking. +type kvStore struct { + dir string + path string + loaded bool + data map[string]string +} + +// newKVStore returns a kvStore rooted at the plugin's data dir. The dir/file +// are created lazily on first write so a plugin that never persists state +// leaves nothing behind. +func newKVStore(dataDir string) *kvStore { + return &kvStore{ + dir: dataDir, + path: filepath.Join(dataDir, "kv.json"), + data: map[string]string{}, + } +} + +func (k *kvStore) ensureLoaded() error { + if k.loaded { + return nil + } + k.loaded = true + data, err := os.ReadFile(k.path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("read plugin kv store: %w", err) + } + parsed := map[string]string{} + if err := json.Unmarshal(data, &parsed); err != nil { + return fmt.Errorf("parse plugin kv store: %w", err) + } + k.data = parsed + return nil +} + +// get returns the value for key and whether it was present. +func (k *kvStore) get(key string) (string, bool, error) { + if err := k.ensureLoaded(); err != nil { + return "", false, err + } + v, ok := k.data[key] + return v, ok, nil +} + +// set stores value under key and flushes the store to disk. +func (k *kvStore) set(key, value string) error { + if err := k.ensureLoaded(); err != nil { + return err + } + k.data[key] = value + return k.flush() +} + +// del removes key and flushes the store. +func (k *kvStore) del(key string) error { + if err := k.ensureLoaded(); err != nil { + return err + } + if _, ok := k.data[key]; !ok { + return nil + } + delete(k.data, key) + return k.flush() +} + +func (k *kvStore) flush() error { + if err := os.MkdirAll(k.dir, 0o750); err != nil { + return fmt.Errorf("create plugin data dir: %w", err) + } + out, err := json.MarshalIndent(k.data, "", " ") + if err != nil { + return fmt.Errorf("marshal plugin kv store: %w", err) + } + tmp := k.path + ".tmp" + if err := os.WriteFile(tmp, out, 0o600); err != nil { + return fmt.Errorf("write plugin kv store: %w", err) + } + if err := os.Rename(tmp, k.path); err != nil { + return fmt.Errorf("commit plugin kv store: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/plugins/kv_test.go b/cmd/entire/cli/plugins/kv_test.go new file mode 100644 index 0000000000..64ace68255 --- /dev/null +++ b/cmd/entire/cli/plugins/kv_test.go @@ -0,0 +1,104 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// writePluginDir creates an arbitrary plugin dir (not under the managed tree) +// with the given manifest and main.lua, returning its path. +func writePluginDir(t *testing.T, manifest, mainLua string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "plug") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ManifestFileName), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, DefaultEntry), []byte(mainLua), 0o600); err != nil { + t.Fatalf("write main.lua: %v", err) + } + return dir +} + +func TestKV_RoundTripAndPersist(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + + dir := writePluginDir(t, + `{"name":"counter","hooks":["turn_end"]}`, + ` +entire.on("turn_end", function() + local prev = entire.kv.get("count") + local n = tonumber(prev or "0") + 1 + entire.kv.set("count", tostring(n)) + _G.last = entire.kv.get("count") +end) +`) + p, err := LoadPlugin(context.Background(), dir, SourceUser, "", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin() error = %v", err) + } + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + reg.FireObserver(context.Background(), HookTurnEnd, nil) + reg.FireObserver(context.Background(), HookTurnEnd, nil) + + if got := p.L.GetGlobal("last").String(); got != "2" { + t.Errorf("kv count = %q, want 2", got) + } + + // Persisted to the per-plugin data dir under the managed parent. + kvPath := filepath.Join(parent, "data", "counter", "kv.json") + data, err := os.ReadFile(kvPath) + if err != nil { + t.Fatalf("read kv file: %v", err) + } + if !strings.Contains(string(data), `"count": "2"`) { + t.Errorf("kv.json missing count=2: %s", data) + } +} + +func TestKV_SurvivesReload(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + + main := ` +entire.on("turn_end", function() + local prev = entire.kv.get("v") + if prev == nil then entire.kv.set("v", "first") else _G.seen = prev end +end) +` + dir := writePluginDir(t, `{"name":"persist","hooks":["turn_end"]}`, main) + + p1, err := LoadPlugin(context.Background(), dir, SourceUser, "", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin() error = %v", err) + } + reg1 := &Registry{} + reg1.Add(p1) + reg1.FireObserver(context.Background(), HookTurnEnd, nil) // sets v=first + reg1.Close() + + // Fresh load must read the persisted value. + p2, err := LoadPlugin(context.Background(), dir, SourceUser, "", settings.PluginSettings{Enabled: true}) + if err != nil { + t.Fatalf("LoadPlugin() reload error = %v", err) + } + reg2 := &Registry{} + reg2.Add(p2) + defer reg2.Close() + reg2.FireObserver(context.Background(), HookTurnEnd, nil) // reads v + + if got := p2.L.GetGlobal("seen").String(); got != "first" { + t.Errorf("reloaded kv value = %q, want first", got) + } +} diff --git a/cmd/entire/cli/plugins/manifest.go b/cmd/entire/cli/plugins/manifest.go new file mode 100644 index 0000000000..41bc9dbb41 --- /dev/null +++ b/cmd/entire/cli/plugins/manifest.go @@ -0,0 +1,129 @@ +// Package plugins implements the embedded Lua plugin runtime for the Entire +// CLI. It lets third parties author no-build-step plugins that subscribe to +// lifecycle/git hooks and contribute commands, layered on top of the existing +// kubectl-style `entire-` binary plugins without replacing them. +// +// The runtime is pure Go (github.com/yuin/gopher-lua) so it builds with +// CGO_ENABLED=0 on every target. Plugins run inside a sandboxed Lua state with +// a curated standard library and are gated by an explicit per-plugin allow-list +// in settings (see settings.PluginSettings); repo-local plugins never auto-run. +package plugins + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// DefaultEntry is the plugin entry script used when a manifest omits "entry". +const DefaultEntry = "main.lua" + +// ManifestFileName is the on-disk name of a plugin's manifest. +const ManifestFileName = "plugin.json" + +// Manifest is the parsed plugin.json describing a Lua plugin. It is decoded +// strictly (unknown fields are rejected) so typos surface at load time rather +// than being silently ignored. +type Manifest struct { + // Name is the plugin's bare identifier. It must be dispatch-safe (no path + // separators, not flag- or agent-protocol-shaped) so it can key the + // allow-list, the data dir, and any contributed command. + Name string `json:"name"` + + // Version is an informational semantic version string. It is not enforced. + Version string `json:"version,omitempty"` + + // Description is a short human-readable summary shown by `entire plugin list`. + Description string `json:"description,omitempty"` + + // Entry is the Lua script executed once at load to register hooks/commands. + // Defaults to DefaultEntry ("main.lua"). + Entry string `json:"entry,omitempty"` + + // Hooks declares the lifecycle/git hooks the plugin intends to subscribe + // to. It is validated against the known hook set; actual subscription + // happens at runtime via entire.on. Declaring hooks lets tooling surface a + // plugin's surface area without executing it. + Hooks []string `json:"hooks,omitempty"` + + // Commands declares the CLI subcommands the plugin contributes. Actual + // registration happens at runtime via entire.command. + Commands []CommandManifest `json:"commands,omitempty"` + + // Capabilities lists the privileged capabilities the plugin requests. A + // capability only takes effect when ALSO granted in the plugin's allow-list + // entry; the manifest field documents intent and is validated against the + // known capability set. + Capabilities []string `json:"capabilities,omitempty"` +} + +// CommandManifest declares a single CLI subcommand contributed by a plugin. +type CommandManifest struct { + // Name is the subcommand name, invoked as `entire `. + Name string `json:"name"` + // Short is the one-line description shown in help output. + Short string `json:"short,omitempty"` +} + +// EntryFile returns the manifest's entry script name, defaulting to DefaultEntry. +func (m *Manifest) EntryFile() string { + if m.Entry == "" { + return DefaultEntry + } + return m.Entry +} + +// ParseManifest decodes and validates a plugin.json payload. Decoding is strict +// (DisallowUnknownFields) and mirrors the settings package's contract so an +// unknown key is a hard error. +func ParseManifest(data []byte) (*Manifest, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var m Manifest + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("parsing plugin manifest: %w", err) + } + if err := m.validate(); err != nil { + return nil, err + } + return &m, nil +} + +// validate enforces the manifest's structural invariants: a dispatch-safe name, +// known hook names, non-empty command names, and known capability names. +func (m *Manifest) validate() error { + if err := ValidatePluginName(m.Name); err != nil { + return fmt.Errorf("plugin manifest name: %w", err) + } + if strings.TrimSpace(m.Entry) != m.Entry { + return fmt.Errorf("plugin %q: entry %q must not have surrounding whitespace", m.Name, m.Entry) + } + if strings.ContainsAny(m.EntryFile(), `/\`) { + return fmt.Errorf("plugin %q: entry %q must be a bare file name in the plugin dir", m.Name, m.EntryFile()) + } + for _, h := range m.Hooks { + if !IsKnownHook(h) { + return fmt.Errorf("plugin %q: unknown hook %q (see docs/architecture/plugins-lua.md)", m.Name, h) + } + } + seen := make(map[string]struct{}, len(m.Commands)) + for _, c := range m.Commands { + if err := ValidatePluginName(c.Name); err != nil { + return fmt.Errorf("plugin %q: command name: %w", m.Name, err) + } + if _, dup := seen[c.Name]; dup { + return fmt.Errorf("plugin %q: duplicate command %q", m.Name, c.Name) + } + seen[c.Name] = struct{}{} + } + for _, capName := range m.Capabilities { + if !settings.IsKnownPluginCapabilityName(capName) { + return fmt.Errorf("plugin %q: unknown capability %q (allowed: %s)", + m.Name, capName, strings.Join(settings.KnownPluginCapabilities(), ", ")) + } + } + return nil +} diff --git a/cmd/entire/cli/plugins/manifest_test.go b/cmd/entire/cli/plugins/manifest_test.go new file mode 100644 index 0000000000..23be74d193 --- /dev/null +++ b/cmd/entire/cli/plugins/manifest_test.go @@ -0,0 +1,98 @@ +package plugins + +import ( + "strings" + "testing" +) + +func TestParseManifest_Valid(t *testing.T) { + t.Parallel() + data := []byte(`{ + "name": "checkpoint-notify", + "version": "1.0.0", + "description": "notifies on checkpoints", + "hooks": ["checkpoint_saved", "post_commit"], + "commands": [{"name": "notify", "short": "send a notification"}], + "capabilities": ["http"] + }`) + m, err := ParseManifest(data) + if err != nil { + t.Fatalf("ParseManifest() error = %v", err) + } + if m.Name != "checkpoint-notify" { + t.Errorf("Name = %q, want checkpoint-notify", m.Name) + } + if m.EntryFile() != DefaultEntry { + t.Errorf("EntryFile() = %q, want %q", m.EntryFile(), DefaultEntry) + } + if len(m.Hooks) != 2 || len(m.Commands) != 1 { + t.Errorf("unexpected hooks/commands: %+v", m) + } +} + +func TestParseManifest_EntryOverride(t *testing.T) { + t.Parallel() + m, err := ParseManifest([]byte(`{"name":"p","entry":"init.lua"}`)) + if err != nil { + t.Fatalf("ParseManifest() error = %v", err) + } + if m.EntryFile() != "init.lua" { + t.Errorf("EntryFile() = %q, want init.lua", m.EntryFile()) + } +} + +func TestParseManifest_RejectsUnknownField(t *testing.T) { + t.Parallel() + _, err := ParseManifest([]byte(`{"name":"p","bogus":true}`)) + if err == nil { + t.Fatal("expected error for unknown field") + } +} + +func TestParseManifest_RejectsUnknownHook(t *testing.T) { + t.Parallel() + _, err := ParseManifest([]byte(`{"name":"p","hooks":["not_a_hook"]}`)) + if err == nil || !strings.Contains(err.Error(), "unknown hook") { + t.Fatalf("expected unknown hook error, got %v", err) + } +} + +func TestParseManifest_RejectsUnknownCapability(t *testing.T) { + t.Parallel() + _, err := ParseManifest([]byte(`{"name":"p","capabilities":["gpu"]}`)) + if err == nil || !strings.Contains(err.Error(), "unknown capability") { + t.Fatalf("expected unknown capability error, got %v", err) + } +} + +func TestParseManifest_RejectsBadName(t *testing.T) { + t.Parallel() + cases := []string{ + `{"name":""}`, + `{"name":"-flag"}`, + `{"name":"agent-foo"}`, + `{"name":"a/b"}`, + `{"name":".."}`, + } + for _, c := range cases { + if _, err := ParseManifest([]byte(c)); err == nil { + t.Errorf("expected error for manifest %s", c) + } + } +} + +func TestParseManifest_RejectsEntryWithPath(t *testing.T) { + t.Parallel() + _, err := ParseManifest([]byte(`{"name":"p","entry":"sub/main.lua"}`)) + if err == nil || !strings.Contains(err.Error(), "bare file name") { + t.Fatalf("expected entry path rejection, got %v", err) + } +} + +func TestParseManifest_RejectsDuplicateCommand(t *testing.T) { + t.Parallel() + _, err := ParseManifest([]byte(`{"name":"p","commands":[{"name":"x"},{"name":"x"}]}`)) + if err == nil || !strings.Contains(err.Error(), "duplicate command") { + t.Fatalf("expected duplicate command error, got %v", err) + } +} diff --git a/cmd/entire/cli/plugins/marshal.go b/cmd/entire/cli/plugins/marshal.go new file mode 100644 index 0000000000..56c27966de --- /dev/null +++ b/cmd/entire/cli/plugins/marshal.go @@ -0,0 +1,159 @@ +package plugins + +import ( + "fmt" + + lua "github.com/yuin/gopher-lua" +) + +// toLuaValue converts a Go value produced by the host into a Lua value inside +// L. Only the JSON-ish shapes the hook payloads use are supported; anything +// else becomes nil so a malformed payload can never inject an unexpected +// userdata or function into plugin scope. +// +// Numeric types are all mapped to lua.LNumber (Lua has a single number type). +func toLuaValue(ls *lua.LState, v any) lua.LValue { + switch val := v.(type) { + case nil: + return lua.LNil + case bool: + return lua.LBool(val) + case string: + return lua.LString(val) + case int: + return lua.LNumber(val) + case int64: + return lua.LNumber(val) + case float64: + return lua.LNumber(val) + case map[string]any: + return toLuaTable(ls, val) + case []string: + arr := ls.NewTable() + for _, s := range val { + arr.Append(lua.LString(s)) + } + return arr + case []any: + arr := ls.NewTable() + for _, item := range val { + arr.Append(toLuaValue(ls, item)) + } + return arr + default: + return lua.LNil + } +} + +// toLuaTable converts a string-keyed Go map into a Lua table inside ls. +func toLuaTable(ls *lua.LState, m map[string]any) *lua.LTable { + tbl := ls.NewTable() + for k, v := range m { + ls.SetField(tbl, k, toLuaValue(ls, v)) + } + return tbl +} + +// fromLuaMaxDepth bounds recursion when converting a Lua value back to Go (the +// inverse of toLuaValue). Lua tables can reference themselves, which would +// otherwise recurse until the Go stack overflows; a self-referential or +// pathologically nested table trips this limit and yields an error instead. It +// is far deeper than any real JSON payload a plugin would hand to entire.json. +const fromLuaMaxDepth = 200 + +// fromLuaValue converts a Lua value into the Go value that encoding/json can +// marshal. It is the inverse of toLuaValue: nil→nil, booleans, numbers→float64 +// (Lua's only number type), strings, and tables→[]any (a 1..n sequence) or +// map[string]any (otherwise). Functions, userdata, threads and channels have no +// JSON form and produce an error. depth guards against cycles/runaway nesting. +func fromLuaValue(v lua.LValue, depth int) (any, error) { + if depth > fromLuaMaxDepth { + return nil, fmt.Errorf("value nested deeper than %d levels (cyclic table?)", fromLuaMaxDepth) + } + switch val := v.(type) { + case *lua.LNilType: + // JSON null: a Go nil interface value with no error is the intended + // result (json.Marshal renders it as null). + return nil, nil //nolint:nilnil // nil is the valid encoding of JSON null + case lua.LBool: + return bool(val), nil + case lua.LNumber: + return float64(val), nil + case lua.LString: + return string(val), nil + case *lua.LTable: + return fromLuaTable(val, depth) + default: + return nil, fmt.Errorf("cannot encode Lua %s", v.Type().String()) + } +} + +// fromLuaTable converts a Lua table into a Go slice or map. A table that is a +// proper 1..n integer-keyed sequence becomes a []any (JSON array); anything else +// (including the empty table) becomes a map[string]any (JSON object), with +// integer keys coerced to their decimal string form. Non-string, non-number keys +// (booleans, nested tables) have no JSON object-key form and produce an error. +func fromLuaTable(t *lua.LTable, depth int) (any, error) { + if n := t.Len(); n > 0 && isLuaSequence(t, n) { + arr := make([]any, 0, n) + for i := 1; i <= n; i++ { + gv, err := fromLuaValue(t.RawGetInt(i), depth+1) + if err != nil { + return nil, err + } + arr = append(arr, gv) + } + return arr, nil + } + + obj := make(map[string]any) + var ferr error + t.ForEach(func(k, v lua.LValue) { + if ferr != nil { + return + } + var key string + switch kk := k.(type) { + case lua.LString: + key = string(kk) + case lua.LNumber: + key = kk.String() + default: + ferr = fmt.Errorf("cannot encode table with a %s key", k.Type().String()) + return + } + gv, err := fromLuaValue(v, depth+1) + if err != nil { + ferr = err + return + } + obj[key] = gv + }) + if ferr != nil { + return nil, ferr + } + return obj, nil +} + +// isLuaSequence reports whether t is a dense array: every key is an integer in +// [1, n] and there are exactly n of them (no gaps, no extra non-integer keys), +// where n is the table's border length. Such a table round-trips to a JSON +// array; any other shape is treated as an object. +func isLuaSequence(t *lua.LTable, n int) bool { + count := 0 + dense := true + t.ForEach(func(k, _ lua.LValue) { + num, ok := k.(lua.LNumber) + if !ok { + dense = false + return + } + i := int(num) + if lua.LNumber(i) != num || i < 1 || i > n { + dense = false + return + } + count++ + }) + return dense && count == n +} diff --git a/cmd/entire/cli/plugins/models_updater_test.go b/cmd/entire/cli/plugins/models_updater_test.go new file mode 100644 index 0000000000..9c6254a62f --- /dev/null +++ b/cmd/entire/cli/plugins/models_updater_test.go @@ -0,0 +1,438 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// These behavior tests drive the shipped examples/plugins/models-updater plugin +// end to end: entire.http is stubbed with an httptest server (the command takes +// the URL as a positional arg), and the fs writes / kv state / exit codes are +// asserted from disk. This exercises the command (fetch/diff/write/--check), +// local-only preservation, and the session_start staleness nudge. + +// modelsUpdaterName is the shipped example plugin under test. +const modelsUpdaterName = "models-updater" + +const modelsUpstream = `{ + "sample_spec": { + "max_tokens": 2048, + "mode": "chat" + }, + "gpt-4o": { + "max_tokens": 4096, + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "litellm_provider": "openai", + "mode": "chat" + }, + "claude-opus-4": { + "max_tokens": 8192, + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "litellm_provider": "anthropic", + "mode": "chat" + } +}` + +// modelsCached is an older cache: gpt-4o has different (higher) rates and there +// is one local-only id (entire-internal-model) absent upstream. +const modelsCached = `{ + "gpt-4o": { + "max_tokens": 4096, + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000015, + "litellm_provider": "openai", + "mode": "chat" + }, + "entire-internal-model": { + "max_tokens": 200000, + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000002, + "litellm_provider": "entire", + "mode": "chat" + } +}` + +// The realistic fixtures mirror tricky shapes in the actual LiteLLM file: +// scientific-notation rates, arrays, nested objects, a string value that +// contains a brace, and a decoy field ("input_cost_per_token_above_128k_tokens") +// whose name has "input_cost_per_token" as a prefix and must NOT be mistaken for +// the real rate field. +const modelsRealisticCached = `{ + "gpt-4o": { + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_128k_tokens": 0.00001, + "output_cost_per_token": 0.000015, + "supported_openai_params": ["temperature", "max_tokens"], + "mode": "chat" + } +}` + +const modelsRealisticUpstream = `{ + "gpt-4o": { + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_128k_tokens": 0.00001, + "output_cost_per_token": 0.000015, + "supported_openai_params": ["temperature", "max_tokens"], + "metadata": { "note": "billed per {token}, commas, and : colons" }, + "mode": "chat" + } +}` + +func TestModelsUpdater_FirstFetchWrites(t *testing.T) { + root := t.TempDir() + reg := loadModelsUpdaterReg(t, root) + url := serveBody(t, modelsUpstream) + + var code int + out := captureStdout(t, func() { + code, _ = reg.RunCommand(context.Background(), "models-update", []string{url}) + }) + + if code != 0 { + t.Fatalf("exit code = %d, want 0; output:\n%s", code, out) + } + if !strings.Contains(out, "first copy") { + t.Errorf("expected first-copy note, got:\n%s", out) + } + if got := readCache(t, root); got != modelsUpstream { + t.Errorf("cache file not written verbatim from upstream:\n%s", got) + } + // The refresh marker was recorded. + if kv := readKV(t); kv["last_updated_session"] == "" { + t.Errorf("expected last_updated_session to be recorded, kv = %v", kv) + } +} + +func TestModelsUpdater_DriftAndPreserveLocalOnly(t *testing.T) { + root := t.TempDir() + writeCache(t, root, modelsCached) + reg := loadModelsUpdaterReg(t, root) + url := serveBody(t, modelsUpstream) + + var code int + out := captureStdout(t, func() { + code, _ = reg.RunCommand(context.Background(), "models-update", []string{url}) + }) + + if code != 0 { + t.Fatalf("exit code = %d, want 0; output:\n%s", code, out) + } + // Drift is detected and reported (gpt-4o input + output changed = 2). + if !strings.Contains(out, "Rate drift detected (2 change(s))") { + t.Errorf("expected 2 drift changes reported, got:\n%s", out) + } + if !strings.Contains(out, "gpt-4o") { + t.Errorf("expected gpt-4o drift line, got:\n%s", out) + } + // The local-only id is reported and preserved on disk. + if !strings.Contains(out, "entire-internal-model") { + t.Errorf("expected local-only id in report, got:\n%s", out) + } + got := readCache(t, root) + if !strings.Contains(got, "entire-internal-model") { + t.Errorf("local-only model erased from written cache:\n%s", got) + } + if !strings.Contains(got, "0.0000025") { + t.Errorf("written cache missing refreshed gpt-4o rate:\n%s", got) + } + if !strings.Contains(got, "claude-opus-4") { + t.Errorf("written cache missing new upstream model:\n%s", got) + } + // The merged file is still valid JSON (upstream bytes + spliced local-only). + if !json.Valid([]byte(got)) { + t.Errorf("merged cache is not valid JSON:\n%s", got) + } +} + +// TestModelsUpdater_HandlesRealisticJSONShape exercises the shipped plugin's +// entire.json.decode path against the awkward shapes present in the real LiteLLM +// file: scientific-notation rates, arrays, nested objects, a brace inside a +// string value, and a decoy field whose name prefixes the real rate field. +func TestModelsUpdater_HandlesRealisticJSONShape(t *testing.T) { + root := t.TempDir() + writeCache(t, root, modelsRealisticCached) + reg := loadModelsUpdaterReg(t, root) + url := serveBody(t, modelsRealisticUpstream) + + var code int + out := captureStdout(t, func() { + code, _ = reg.RunCommand(context.Background(), "models-update", []string{url}) + }) + + if code != 0 { + t.Fatalf("exit code = %d, want 0; output:\n%s", code, out) + } + // Exactly one rate change: input 5e-06 -> 3e-06 (scientific notation parsed). + // The decoy input_cost_per_token_above_128k_tokens is a distinct key that the + // exact-field lookup ignores, and the unchanged output rate must NOT be + // counted; the "{token}" string / array / nested object are decoded natively. + if !strings.Contains(out, "Rate drift detected (1 change(s))") { + t.Errorf("expected exactly 1 drift change, got:\n%s", out) + } + if !strings.Contains(out, "input 5e-06 -> 3e-06") { + t.Errorf("expected scientific-notation input drift line, got:\n%s", out) + } + if got := readCache(t, root); !json.Valid([]byte(got)) { + t.Errorf("written cache is not valid JSON:\n%s", got) + } +} + +func TestModelsUpdater_CheckDetectsDriftNonZeroNoWrite(t *testing.T) { + root := t.TempDir() + writeCache(t, root, modelsCached) + reg := loadModelsUpdaterReg(t, root) + url := serveBody(t, modelsUpstream) + + var code int + out := captureStdout(t, func() { + code, _ = reg.RunCommand(context.Background(), "models-update", []string{"--check", url}) + }) + + if code != 1 { + t.Fatalf("--check with drift: exit code = %d, want 1; output:\n%s", code, out) + } + if !strings.Contains(out, "nothing written") { + t.Errorf("expected --check to note nothing written, got:\n%s", out) + } + // --check must not modify the cache. + if got := readCache(t, root); got != modelsCached { + t.Errorf("--check modified the cache file:\n%s", got) + } +} + +func TestModelsUpdater_CheckCleanWhenInSync(t *testing.T) { + root := t.TempDir() + writeCache(t, root, modelsUpstream) // cache already matches upstream + reg := loadModelsUpdaterReg(t, root) + url := serveBody(t, modelsUpstream) + + var code int + out := captureStdout(t, func() { + code, _ = reg.RunCommand(context.Background(), "models-update", []string{"--check", url}) + }) + + if code != 0 { + t.Fatalf("--check with no drift: exit code = %d, want 0; output:\n%s", code, out) + } + if !strings.Contains(out, "No rate drift") { + t.Errorf("expected no-drift note, got:\n%s", out) + } +} + +func TestModelsUpdater_HTTPErrorExitsNonZero(t *testing.T) { + root := t.TempDir() + reg := loadModelsUpdaterReg(t, root) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + var code int + out := captureStdout(t, func() { + code, _ = reg.RunCommand(context.Background(), "models-update", []string{srv.URL}) + }) + if code != 1 { + t.Fatalf("HTTP 500: exit code = %d, want 1; output:\n%s", code, out) + } + if _, err := os.Stat(filepath.Join(root, ".entire", "models.json")); err == nil { + t.Error("cache should not be written on HTTP error") + } +} + +func TestModelsUpdater_SessionStartNudgesWhenNeverFetched(t *testing.T) { + root := t.TempDir() + reg := loadModelsUpdaterReg(t, root) + logs := captureSlog(t) + + reg.FireObserver(context.Background(), HookSessionStart, nil) + + if !strings.Contains(logs.String(), "never been fetched") { + t.Errorf("expected never-fetched nudge, got logs:\n%s", logs.String()) + } + // The logical session clock was bumped even on the never-fetched path. + if kv := readKV(t); kv["session_count"] != "1" { + t.Errorf("session_count = %q, want 1; kv = %v", kv["session_count"], kv) + } +} + +func TestModelsUpdater_SessionStartNudgesWhenStale(t *testing.T) { + root := t.TempDir() + reg := loadModelsUpdaterReg(t, root) + // Last refreshed 30 sessions ago (>= the 25-session staleness threshold). + seedKV(t, map[string]string{ + "session_count": "30", + "last_updated_session": "0", + }) + logs := captureSlog(t) + + reg.FireObserver(context.Background(), HookSessionStart, nil) + + if !strings.Contains(logs.String(), "last refreshed") { + t.Errorf("expected stale nudge, got logs:\n%s", logs.String()) + } + if kv := readKV(t); kv["session_count"] != "31" { + t.Errorf("session_count = %q, want 31", kv["session_count"]) + } +} + +func TestModelsUpdater_SessionStartQuietWhenFresh(t *testing.T) { + root := t.TempDir() + reg := loadModelsUpdaterReg(t, root) + // Refreshed this session: nothing stale. + seedKV(t, map[string]string{ + "session_count": "5", + "last_updated_session": "5", + }) + logs := captureSlog(t) + + reg.FireObserver(context.Background(), HookSessionStart, nil) + + if s := logs.String(); strings.Contains(s, "models-update") { + t.Errorf("expected no nudge on the fresh path, got logs:\n%s", s) + } + if kv := readKV(t); kv["session_count"] != "6" { + t.Errorf("session_count = %q, want 6", kv["session_count"]) + } +} + +// --- helpers --- + +// loadModelsUpdaterReg loads the real examples/plugins/models-updater plugin +// with the http+fs grant and worktreeRoot=root, into a fresh registry. It +// points ENTIRE_PLUGIN_DIR at a per-test temp dir so kv state is isolated. +func loadModelsUpdaterReg(t *testing.T, root string) *Registry { + t.Helper() + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + dir := filepath.Join(examplesDir(t), modelsUpdaterName) + grant := settings.PluginSettings{ + Enabled: true, + Capabilities: []string{settings.PluginCapabilityHTTP, settings.PluginCapabilityFS}, + } + p, err := LoadPlugin(context.Background(), dir, SourceUser, root, grant) + if err != nil { + t.Fatalf("LoadPlugin(models-updater) error = %v", err) + } + reg := &Registry{} + reg.Add(p) + t.Cleanup(reg.Close) + return reg +} + +// serveBody starts an httptest server that returns body for any request and +// returns its URL. This is how entire.http is "stubbed": the command accepts +// the URL as a positional argument. +func serveBody(t *testing.T, body string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, body) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// captureStdout redirects os.Stdout while fn runs and returns what was written. +// entire.print writes to os.Stdout, so this captures a command's user output. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + if _, cerr := io.Copy(&buf, r); cerr != nil { + buf.WriteString("\n[captureStdout: copy error: " + cerr.Error() + "]") + } + done <- buf.String() + }() + fn() + _ = w.Close() + os.Stdout = old + out := <-done + _ = r.Close() + return out +} + +// captureSlog installs a capturing slog default handler (the logging package +// falls back to slog.Default() when uninitialized, which it is in this package's +// tests) and restores it on cleanup. entire.log.* lines land in the buffer. +func captureSlog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(old) }) + return &buf +} + +func writeCache(t *testing.T, root, body string) { + t.Helper() + dir := filepath.Join(root, ".entire") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir cache dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "models.json"), []byte(body), 0o600); err != nil { + t.Fatalf("write cache: %v", err) + } +} + +func readCache(t *testing.T, root string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, ".entire", "models.json")) + if err != nil { + t.Fatalf("read cache: %v", err) + } + return string(b) +} + +func seedKV(t *testing.T, data map[string]string) { + t.Helper() + dir, err := PluginDataDir(modelsUpdaterName) + if err != nil { + t.Fatalf("PluginDataDir: %v", err) + } + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir data dir: %v", err) + } + b, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal kv: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "kv.json"), b, 0o600); err != nil { + t.Fatalf("write kv: %v", err) + } +} + +func readKV(t *testing.T) map[string]string { + t.Helper() + dir, err := PluginDataDir(modelsUpdaterName) + if err != nil { + t.Fatalf("PluginDataDir: %v", err) + } + b, err := os.ReadFile(filepath.Join(dir, "kv.json")) + if err != nil { + t.Fatalf("read kv: %v", err) + } + m := map[string]string{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("parse kv: %v", err) + } + return m +} diff --git a/cmd/entire/cli/plugins/mutate.go b/cmd/entire/cli/plugins/mutate.go new file mode 100644 index 0000000000..fbcc714993 --- /dev/null +++ b/cmd/entire/cli/plugins/mutate.go @@ -0,0 +1,156 @@ +package plugins + +import ( + "context" + "fmt" + "log/slog" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/settings" + lua "github.com/yuin/gopher-lua" +) + +// Mutating hooks differ from observer hooks in that their return values affect +// CLI behavior. They are additionally capability-gated and have deterministic +// multi-plugin semantics: plugins are consulted in load order (user-dir plugins +// before repo-local, discovery order within each), which is stable across runs. +// +// Ordering vs built-in behavior: +// - prepare_commit_msg: plugin trailers are appended AFTER the strategy's own +// Entire-Checkpoint trailer, so the built-in linkage trailer is never +// displaced. Multiple plugins' trailers are appended in load order. +// - pre_push: the plugin veto runs BEFORE the built-in OPF rewrite and +// checkpoint-ref push, so a veto short-circuits that work. A veto aborts the +// user's push (non-zero hook exit); the built-in OPF abort path is +// unaffected and independent. + +// FireCommitMsg dispatches the prepare_commit_msg mutating hook and returns the +// trailer strings contributed by plugins granted the commit_msg capability, in +// load order. A plugin that registered the hook without the capability is +// skipped (it cannot mutate the commit message). Errors and panics are isolated +// and drop only that callback's contribution. +func (r *Registry) FireCommitMsg(ctx context.Context, payload map[string]any) []string { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + logCtx := logging.WithComponent(ctx, "plugins") + + var out []string + for _, p := range r.plugins { + cbs := p.callbacks[HookPrepareCommitMsg] + if len(cbs) == 0 { + continue + } + if !p.Grant.HasCapability(settings.PluginCapabilityCommitMsg) { + logging.Debug(logCtx, "skip prepare_commit_msg: capability not granted", + slog.String("plugin", p.Manifest.Name)) + continue + } + for _, cb := range cbs { + if s, ok := r.callString(ctx, logCtx, p, HookPrepareCommitMsg, cb, payload); ok && s != "" { + out = append(out, s) + } + } + } + return out +} + +// FirePrePush dispatches the pre_push hook. Every plugin's callback runs (its +// observer side effects), but only a plugin granted the pre_push capability may +// veto: returning false (with an optional reason string) aborts the push. The +// first veto in load order supplies the reported reason; all callbacks still +// run. Returns a non-nil error when the push is vetoed. +func (r *Registry) FirePrePush(ctx context.Context, payload map[string]any) error { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + logCtx := logging.WithComponent(ctx, "plugins") + + var vetoErr error + for _, p := range r.plugins { + cbs := p.callbacks[HookPrePush] + if len(cbs) == 0 { + continue + } + canVeto := p.Grant.HasCapability(settings.PluginCapabilityPrePush) + for _, cb := range cbs { + vetoed, reason := r.callVeto(ctx, logCtx, p, cb, payload) + if canVeto && vetoed && vetoErr == nil { + if reason == "" { + reason = "no reason given" + } + vetoErr = fmt.Errorf("push vetoed by plugin %q: %s", p.Manifest.Name, reason) + } + } + } + return vetoErr +} + +// callString invokes a mutating callback expecting a single string return, +// bounded by the hook timeout with panic/error isolation. +func (r *Registry) callString(ctx, logCtx context.Context, p *LoadedPlugin, hook string, cb *lua.LFunction, payload map[string]any) (result string, ok bool) { + cctx, cancel := context.WithTimeout(ctx, hookTimeout()) + defer cancel() + p.dispatchCtx = cctx + p.L.SetContext(cctx) + defer p.L.SetContext(context.Background()) + defer func() { + if rec := recover(); rec != nil { + logging.Warn(logCtx, "mutating hook panicked", + slog.String("plugin", p.Manifest.Name), slog.String("hook", hook)) + } + }() + + arg := toLuaTable(p.L, payload) + if err := p.L.CallByParam(lua.P{Fn: cb, NRet: 1, Protect: true}, arg); err != nil { + logging.Warn(logCtx, "mutating hook callback failed", + slog.String("plugin", p.Manifest.Name), slog.String("hook", hook), slog.String("error", err.Error())) + return "", false + } + ret := p.L.Get(-1) + p.L.Pop(1) + if s, isStr := ret.(lua.LString); isStr { + return string(s), true + } + return "", false +} + +// callVeto invokes a pre_push callback, interpreting a boolean false first +// return as a veto and an optional second string as the reason. +func (r *Registry) callVeto(ctx, logCtx context.Context, p *LoadedPlugin, cb *lua.LFunction, payload map[string]any) (vetoed bool, reason string) { + cctx, cancel := context.WithTimeout(ctx, hookTimeout()) + defer cancel() + p.dispatchCtx = cctx + p.L.SetContext(cctx) + defer p.L.SetContext(context.Background()) + defer func() { + if rec := recover(); rec != nil { + logging.Warn(logCtx, "pre_push hook panicked", + slog.String("plugin", p.Manifest.Name)) + } + }() + + arg := toLuaTable(p.L, payload) + if err := p.L.CallByParam(lua.P{Fn: cb, NRet: 2, Protect: true}, arg); err != nil { + logging.Warn(logCtx, "pre_push hook callback failed", + slog.String("plugin", p.Manifest.Name), slog.String("error", err.Error())) + return false, "" + } + // Returns are pushed in order; with NRet:2 the stack top-1 is the first + // return and top is the second. + second := p.L.Get(-1) + first := p.L.Get(-2) + p.L.Pop(2) + + if b, isBool := first.(lua.LBool); isBool && !bool(b) { + vetoed = true + } + if s, isStr := second.(lua.LString); isStr { + reason = string(s) + } + return vetoed, reason +} diff --git a/cmd/entire/cli/plugins/mutate_test.go b/cmd/entire/cli/plugins/mutate_test.go new file mode 100644 index 0000000000..a630373c85 --- /dev/null +++ b/cmd/entire/cli/plugins/mutate_test.go @@ -0,0 +1,121 @@ +package plugins + +import ( + "context" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// loadMut loads a plugin from inline main.lua with the given grant (no fire). +func loadMut(t *testing.T, name, manifest, mainLua string, grant settings.PluginSettings) *LoadedPlugin { + t.Helper() + t.Setenv("ENTIRE_PLUGIN_DIR", t.TempDir()) + dir := writePluginDir(t, manifest, mainLua) + p, err := LoadPlugin(context.Background(), dir, SourceUser, "", grant) + if err != nil { + t.Fatalf("LoadPlugin(%s) error = %v", name, err) + } + return p +} + +func TestFireCommitMsg_TrailerFromCapablePlugin(t *testing.T) { + p := loadMut(t, "ct", + `{"name":"ct","hooks":["prepare_commit_msg"],"capabilities":["commit_msg"]}`, + `entire.on("prepare_commit_msg", function(ev) return "Plugin-Trailer: " .. ev.source end)`, + settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityCommitMsg}}) + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + got := reg.FireCommitMsg(context.Background(), map[string]any{"source": "message"}) + if len(got) != 1 || got[0] != "Plugin-Trailer: message" { + t.Fatalf("FireCommitMsg = %v, want [Plugin-Trailer: message]", got) + } +} + +func TestFireCommitMsg_SkipsUncapablePlugin(t *testing.T) { + p := loadMut(t, "ct", + `{"name":"ct","hooks":["prepare_commit_msg"]}`, + `entire.on("prepare_commit_msg", function() return "X: y" end)`, + settings.PluginSettings{Enabled: true}) // no commit_msg cap + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + if got := reg.FireCommitMsg(context.Background(), nil); len(got) != 0 { + t.Fatalf("expected no trailers without capability, got %v", got) + } +} + +func TestFireCommitMsg_MultiPluginLoadOrder(t *testing.T) { + p1 := loadMut(t, "a", + `{"name":"a","hooks":["prepare_commit_msg"],"capabilities":["commit_msg"]}`, + `entire.on("prepare_commit_msg", function() return "A: 1" end)`, + settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityCommitMsg}}) + p2 := loadMut(t, "b", + `{"name":"b","hooks":["prepare_commit_msg"],"capabilities":["commit_msg"]}`, + `entire.on("prepare_commit_msg", function() return "B: 2" end)`, + settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityCommitMsg}}) + reg := &Registry{} + reg.Add(p1) + reg.Add(p2) + defer reg.Close() + + got := reg.FireCommitMsg(context.Background(), nil) + if len(got) != 2 || got[0] != "A: 1" || got[1] != "B: 2" { + t.Fatalf("FireCommitMsg order = %v, want [A: 1, B: 2]", got) + } +} + +func TestFirePrePush_VetoWithCapability(t *testing.T) { + p := loadMut(t, "veto", + `{"name":"veto","hooks":["pre_push"],"capabilities":["pre_push"]}`, + `entire.on("pre_push", function(ev) return false, "remote is protected" end)`, + settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityPrePush}}) + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + err := reg.FirePrePush(context.Background(), map[string]any{"remote": "origin"}) + if err == nil || !strings.Contains(err.Error(), "remote is protected") { + t.Fatalf("expected veto error, got %v", err) + } +} + +func TestFirePrePush_NoVetoWithoutCapability(t *testing.T) { + // Callback returns false but the plugin lacks the pre_push capability, so + // the return is ignored (observer only) and the push proceeds. + p := loadMut(t, "obs", + `{"name":"obs","hooks":["pre_push"]}`, + ` +_G.ran = false +entire.on("pre_push", function() _G.ran = true; return false end) +`, + settings.PluginSettings{Enabled: true}) + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + if err := reg.FirePrePush(context.Background(), nil); err != nil { + t.Fatalf("expected no veto without capability, got %v", err) + } + if p.L.GetGlobal("ran").String() != "true" { + t.Error("expected observer callback to still run without the capability") + } +} + +func TestFirePrePush_AllowWhenTrue(t *testing.T) { + p := loadMut(t, "ok", + `{"name":"ok","hooks":["pre_push"],"capabilities":["pre_push"]}`, + `entire.on("pre_push", function() return true end)`, + settings.PluginSettings{Enabled: true, Capabilities: []string{settings.PluginCapabilityPrePush}}) + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + if err := reg.FirePrePush(context.Background(), nil); err != nil { + t.Fatalf("expected push allowed when callback returns true, got %v", err) + } +} diff --git a/cmd/entire/cli/plugins/names.go b/cmd/entire/cli/plugins/names.go new file mode 100644 index 0000000000..478794dcd5 --- /dev/null +++ b/cmd/entire/cli/plugins/names.go @@ -0,0 +1,35 @@ +package plugins + +import ( + "errors" + "fmt" + "strings" +) + +// ValidatePluginName enforces the same dispatch-safety rules the kubectl-style +// binary dispatcher applies to `entire-` (see validatePluginName in +// cmd/entire/cli/plugin_store.go). It is duplicated here rather than imported +// because the cli package imports plugins, so plugins cannot import cli. +// +// The rules must stay in lockstep with the binary dispatcher: a Lua plugin +// contributes commands resolved as `entire `, and its data dir is keyed +// by name, so a name the binary dispatcher would reject must be rejected here +// too. +func ValidatePluginName(name string) error { + if name == "" { + return errors.New("plugin name is empty") + } + if strings.HasPrefix(name, "-") { + return fmt.Errorf("plugin name %q must not start with '-'", name) + } + if strings.HasPrefix(name, "agent-") { + return fmt.Errorf("plugin name %q is reserved for the external agent protocol", name) + } + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("plugin name %q must not contain path separators", name) + } + if name == "." || name == ".." { + return fmt.Errorf("plugin name %q is not a valid identifier", name) + } + return nil +} diff --git a/cmd/entire/cli/plugins/paths.go b/cmd/entire/cli/plugins/paths.go new file mode 100644 index 0000000000..0d77c0952a --- /dev/null +++ b/cmd/entire/cli/plugins/paths.go @@ -0,0 +1,95 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +// Managed storage layout constants. These MUST match the values used by the +// binary plugin store (cmd/entire/cli/plugin_store.go); the parent-dir +// resolution is duplicated here (rather than imported) because the cli package +// imports plugins, so plugins cannot import cli. The duplication is small and +// stable; a mismatch would split Lua and binary plugins across different +// per-user trees. +const ( + pluginEnvPluginDir = "ENTIRE_PLUGIN_DIR" + pluginManagedTopDir = "entire" + pluginManagedSubDir = "plugins" + + // luaSubDir is the subdirectory under the managed plugin parent that holds + // Lua plugins, one directory per plugin. Kept separate from the binary + // store's bin/ and data/ subdirs so a Lua plugin dir is never confused with + // an `entire-` executable or a binary plugin's data dir. + luaSubDir = "lua" + + // dataSubDir holds per-plugin durable key/value storage (entire.kv), + // namespaced by plugin name and shared by Lua plugins from either source. + dataSubDir = "data" + + windowsGOOS = "windows" +) + +// pluginParentDir mirrors cli.pluginParentDir: the per-user directory that +// holds the managed plugin storage. See that function for the full rationale of +// the resolution order and platform conventions. +func pluginParentDir() (string, error) { + if v := os.Getenv(pluginEnvPluginDir); v != "" { + if !filepath.IsAbs(v) { + return "", fmt.Errorf("%s must be an absolute path, got %q", pluginEnvPluginDir, v) + } + return v, nil + } + if runtime.GOOS == windowsGOOS { + if appData := os.Getenv("LOCALAPPDATA"); appData != "" { + return filepath.Join(appData, pluginManagedTopDir, pluginManagedSubDir), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, "AppData", "Local", pluginManagedTopDir, pluginManagedSubDir), nil + } + if v := os.Getenv("XDG_DATA_HOME"); v != "" { + return filepath.Join(v, pluginManagedTopDir, pluginManagedSubDir), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, ".local", "share", pluginManagedTopDir, pluginManagedSubDir), nil +} + +// UserLuaPluginsDir returns the per-user directory that holds installed Lua +// plugins (one subdirectory per plugin). It is the sibling of the binary +// store's bin/ and data/ dirs. +func UserLuaPluginsDir() (string, error) { + parent, err := pluginParentDir() + if err != nil { + return "", err + } + return filepath.Join(parent, luaSubDir), nil +} + +// PluginDataDir returns the durable per-plugin data directory used by +// entire.kv. The name must be dispatch-safe; the directory is not created here +// (callers create it lazily on first write) so a plugin that never persists +// state leaves no directory behind. +func PluginDataDir(name string) (string, error) { + if err := ValidatePluginName(name); err != nil { + return "", err + } + parent, err := pluginParentDir() + if err != nil { + return "", err + } + return filepath.Join(parent, dataSubDir, name), nil +} + +// RepoLuaPluginsDir returns the repo-local Lua plugin directory +// (.entire/plugins) under the given worktree root. Plugins discovered here +// NEVER auto-run without an explicit allow-list entry. +func RepoLuaPluginsDir(worktreeRoot string) string { + return filepath.Join(worktreeRoot, ".entire", "plugins") +} diff --git a/cmd/entire/cli/plugins/plugin.go b/cmd/entire/cli/plugins/plugin.go new file mode 100644 index 0000000000..a43834ff4a --- /dev/null +++ b/cmd/entire/cli/plugins/plugin.go @@ -0,0 +1,122 @@ +package plugins + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/settings" + lua "github.com/yuin/gopher-lua" +) + +// Source identifies where a plugin was discovered. +type Source string + +const ( + // SourceUser is the per-user managed Lua plugins dir (installed plugins). + SourceUser Source = "user" + // SourceRepo is the repo-local .entire/plugins dir. Repo-local plugins + // never auto-run without an explicit allow-list entry. + SourceRepo Source = "repo" +) + +// LoadedPlugin is a plugin whose entry script has run and whose hook +// subscriptions have been registered. Its Lua state is long-lived: callbacks +// captured at load are invoked on each matching hook. LStates are NOT +// goroutine-safe; the owning Registry serializes all access. +type LoadedPlugin struct { + Manifest Manifest + Dir string + Source Source + Grant settings.PluginSettings + + // WorktreeRoot is the repo root at load time, exposed to Lua as + // entire.repo_root. Empty when loaded outside a repo. + WorktreeRoot string + + L *lua.LState + callbacks map[string][]*lua.LFunction + commands map[string]*commandEntry + kv *kvStore + + // dispatchCtx is the context of the in-flight load or hook dispatch, used + // only for logging correlation. It is set under the Registry mutex before + // each callback runs, so no additional synchronization is needed. + dispatchCtx context.Context //nolint:containedctx // set per-dispatch under the registry mutex; carries logging correlation only +} + +// LoadManifestFromDir reads and validates the plugin.json in dir without +// executing any Lua. Used by discovery and `entire plugin list` to inspect a +// plugin cheaply. +func LoadManifestFromDir(dir string) (*Manifest, error) { + manifestPath := filepath.Join(dir, ManifestFileName) + data, err := os.ReadFile(manifestPath) //nolint:gosec // dir is a resolved plugin dir (managed store or repo .entire/plugins); reading its manifest is the point + if err != nil { + return nil, fmt.Errorf("read plugin manifest %s: %w", manifestPath, err) + } + return ParseManifest(data) +} + +// LoadPlugin builds a sandboxed Lua state for the plugin in dir, installs the +// entire API, and runs the entry script once so it can register hooks and +// commands. The caller owns the returned plugin's Lua state and must Close it +// (via the Registry) when done. +// +// grant is the plugin's allow-list entry; callers must have already confirmed +// the plugin is enabled. worktreeRoot is exposed to Lua (may be empty). ctx +// bounds the entry-script execution. +func LoadPlugin(ctx context.Context, dir string, source Source, worktreeRoot string, grant settings.PluginSettings) (*LoadedPlugin, error) { + manifest, err := LoadManifestFromDir(dir) + if err != nil { + return nil, err + } + + L := NewSandboxedState(context.Background()) + p := &LoadedPlugin{ + Manifest: *manifest, + Dir: dir, + Source: source, + Grant: grant, + WorktreeRoot: worktreeRoot, + L: L, + callbacks: make(map[string][]*lua.LFunction), + commands: make(map[string]*commandEntry), + dispatchCtx: ctx, + } + if dataDir, derr := PluginDataDir(manifest.Name); derr == nil { + p.kv = newKVStore(dataDir) + } + p.installAPI(L) + + entryPath := filepath.Join(dir, manifest.EntryFile()) + if _, statErr := os.Stat(entryPath); statErr != nil { + L.Close() + return nil, fmt.Errorf("plugin %q: entry script: %w", manifest.Name, statErr) + } + + loadCtx, cancel := context.WithTimeout(ctx, loadTimeout) + defer cancel() + L.SetContext(loadCtx) + if err := L.DoFile(entryPath); err != nil { + L.Close() + return nil, fmt.Errorf("plugin %q: run entry %s: %w", manifest.Name, manifest.EntryFile(), err) + } + // Clear the load deadline; each hook dispatch installs its own. + L.SetContext(context.Background()) + + return p, nil +} + +// Close releases the plugin's Lua state. Safe to call once. +func (p *LoadedPlugin) Close() { + if p.L != nil { + p.L.Close() + p.L = nil + } +} + +// HasHook reports whether the plugin registered any callback for the hook. +func (p *LoadedPlugin) HasHook(hook string) bool { + return len(p.callbacks[hook]) > 0 +} diff --git a/cmd/entire/cli/plugins/registry.go b/cmd/entire/cli/plugins/registry.go new file mode 100644 index 0000000000..52940b706b --- /dev/null +++ b/cmd/entire/cli/plugins/registry.go @@ -0,0 +1,240 @@ +package plugins + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "sort" + "sync" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/settings" + lua "github.com/yuin/gopher-lua" +) + +// Registry holds the loaded, allow-listed plugins for a process and dispatches +// hook events to them. All access to plugin Lua states goes through the +// registry, which serializes it (LStates are not goroutine-safe). +type Registry struct { + // mu serializes hook dispatch: LStates are not goroutine-safe, so at most + // one callback runs at a time even if two hooks fire concurrently. + mu sync.Mutex + plugins []*LoadedPlugin +} + +// discoverySource pairs a directory to scan with the origin to tag plugins from +// it. User plugins are scanned before repo plugins so a user-installed plugin +// wins a name collision with a repo-local one. +type discoverySource struct { + dir string + source Source +} + +// Discover loads every allow-listed, enabled plugin from the per-user managed +// dir and the repo-local .entire/plugins dir. worktreeRoot may be empty when +// not inside a repo (only user plugins are considered then). A plugin is loaded +// only when settings has an entry for it with enabled=true; repo-local plugins +// therefore never auto-run without an explicit opt-in. +// +// s is the merged team+local allow-list and governs user-global plugins. +// localGrants is the allow-list from the per-developer .entire/settings.local.json +// only (see settings.LocalPluginGrants) and is the *sole* authority for +// repo-local plugins: a committed team .entire/settings.json can neither enable +// a repo-local plugin nor grant it capabilities. This closes the trust hole +// where cloning a hostile repo that ships both a .entire/plugins/ +// directory and a team settings enable would auto-run the plugin's code. +// +// Discovery is resilient: a plugin that fails to parse or load is logged and +// skipped rather than failing the whole registry — one broken third-party +// plugin must not break the CLI. +func Discover(ctx context.Context, worktreeRoot string, s *settings.EntireSettings, localGrants map[string]settings.PluginSettings) *Registry { + logCtx := logging.WithComponent(ctx, "plugins") + r := &Registry{} + // Kill switch: an operator can disable all plugins process-wide regardless + // of the allow-list. + if pluginsDisabledByEnv() { + logging.Debug(logCtx, "lua plugins disabled via "+pluginsDisabledEnv) + return r + } + // Fast path: with no enabled allow-list entry, nothing can run. Skip all + // filesystem work so the common (no-plugins) case is essentially free. + if !hasEnabledPlugin(s) { + return r + } + + var sources []discoverySource + if userDir, err := UserLuaPluginsDir(); err == nil { + sources = append(sources, discoverySource{dir: userDir, source: SourceUser}) + } else { + logging.Debug(logCtx, "skip user plugin dir: resolve failed", slog.String("error", err.Error())) + } + if worktreeRoot != "" { + sources = append(sources, discoverySource{dir: RepoLuaPluginsDir(worktreeRoot), source: SourceRepo}) + } + + loaded := make(map[string]struct{}) + for _, src := range sources { + entries, err := os.ReadDir(src.dir) + if err != nil { + if !os.IsNotExist(err) { + logging.Debug(logCtx, "read plugin dir failed", slog.String("dir", src.dir), slog.String("error", err.Error())) + } + continue + } + for _, e := range entries { + if !e.IsDir() { + continue + } + pluginDir := filepath.Join(src.dir, e.Name()) + r.tryLoadCandidate(ctx, logCtx, pluginDir, src.source, worktreeRoot, s, localGrants, loaded) + } + } + return r +} + +// hasEnabledPlugin reports whether settings enables at least one plugin. +func hasEnabledPlugin(s *settings.EntireSettings) bool { + if s == nil { + return false + } + for _, ps := range s.Plugins { + if ps.Enabled { + return true + } + } + return false +} + +// grantForSource returns the allow-list entry that governs a plugin and whether +// one exists, enforcing that repo-local plugins may only be enabled from the +// per-developer local settings file. +// +// User-global plugins (SourceUser) use the merged team+local allow-list, as +// before. Repo-local plugins (SourceRepo) use ONLY the grants sourced from +// .entire/settings.local.json: a committed team .entire/settings.json can +// neither enable a repo-local plugin nor grant it capabilities. This is the +// trust boundary — cloning a hostile repo that ships both a .entire/plugins/ +// directory and a team settings enable must not execute the plugin's code. +func grantForSource(source Source, name string, s *settings.EntireSettings, localGrants map[string]settings.PluginSettings) (settings.PluginSettings, bool) { + if source == SourceRepo { + grant, ok := localGrants[name] + return grant, ok + } + return s.PluginGrant(name) +} + +// tryLoadCandidate parses a candidate plugin dir, checks the allow-list, and +// loads it when enabled. Failures are logged and skipped. +func (r *Registry) tryLoadCandidate(ctx, logCtx context.Context, dir string, source Source, worktreeRoot string, s *settings.EntireSettings, localGrants map[string]settings.PluginSettings, loaded map[string]struct{}) { + if _, statErr := os.Stat(filepath.Join(dir, ManifestFileName)); statErr != nil { + return // not a plugin dir + } + manifest, err := LoadManifestFromDir(dir) + if err != nil { + logging.Warn(logCtx, "skip plugin: invalid manifest", slog.String("dir", dir), slog.String("error", err.Error())) + return + } + name := manifest.Name + if _, dup := loaded[name]; dup { + logging.Warn(logCtx, "skip plugin: name already loaded from higher-precedence source", + slog.String("plugin", name), slog.String("dir", dir), slog.String("source", string(source))) + return + } + grant, ok := grantForSource(source, name, s, localGrants) + if !ok || !grant.Enabled { + // Not allow-listed (or disabled): never auto-run. Logged at debug so a + // repo shipping a plugin the user hasn't opted into stays quiet. + logging.Debug(logCtx, "skip plugin: not enabled in allow-list", + slog.String("plugin", name), slog.String("source", string(source))) + return + } + p, err := LoadPlugin(ctx, dir, source, worktreeRoot, grant) + if err != nil { + logging.Warn(logCtx, "skip plugin: load failed", slog.String("plugin", name), slog.String("error", err.Error())) + return + } + loaded[name] = struct{}{} + r.plugins = append(r.plugins, p) + logging.Debug(logCtx, "loaded lua plugin", + slog.String("plugin", name), slog.String("source", string(source)), slog.Int("hooks", len(p.callbacks))) +} + +// Add appends an already-loaded plugin to the registry. Used by tests and by +// callers that load plugins directly. +func (r *Registry) Add(p *LoadedPlugin) { + r.plugins = append(r.plugins, p) +} + +// Plugins returns the loaded plugins in load order. +func (r *Registry) Plugins() []*LoadedPlugin { + return r.plugins +} + +// Len returns the number of loaded plugins. +func (r *Registry) Len() int { + return len(r.plugins) +} + +// PluginNames returns the sorted names of loaded plugins. +func (r *Registry) PluginNames() []string { + names := make([]string, 0, len(r.plugins)) + for _, p := range r.plugins { + names = append(names, p.Manifest.Name) + } + sort.Strings(names) + return names +} + +// FireObserver dispatches an observer hook to every subscribed plugin. Observer +// callbacks run for side effects only: a failing or slow callback is logged and +// ignored, never propagated to the host. Each callback runs under its own +// timeout so one plugin cannot stall the hook. +func (r *Registry) FireObserver(ctx context.Context, hook string, payload map[string]any) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if len(r.plugins) == 0 { + return + } + logCtx := logging.WithComponent(ctx, "plugins") + for _, p := range r.plugins { + for _, cb := range p.callbacks[hook] { + r.invokeObserver(ctx, logCtx, p, hook, cb, payload) + } + } +} + +// invokeObserver runs a single observer callback with a bounded context and +// panic isolation. +func (r *Registry) invokeObserver(ctx, logCtx context.Context, p *LoadedPlugin, hook string, cb *lua.LFunction, payload map[string]any) { + cctx, cancel := context.WithTimeout(ctx, hookTimeout()) + defer cancel() + + p.dispatchCtx = cctx + p.L.SetContext(cctx) + defer p.L.SetContext(context.Background()) + + defer func() { + if rec := recover(); rec != nil { + logging.Warn(logCtx, "observer hook panicked", + slog.String("plugin", p.Manifest.Name), slog.String("hook", hook)) + } + }() + + arg := toLuaTable(p.L, payload) + if err := p.L.CallByParam(lua.P{Fn: cb, NRet: 0, Protect: true}, arg); err != nil { + logging.Warn(logCtx, "observer hook callback failed", + slog.String("plugin", p.Manifest.Name), slog.String("hook", hook), slog.String("error", err.Error())) + } +} + +// Close releases every plugin's Lua state. +func (r *Registry) Close() { + for _, p := range r.plugins { + p.Close() + } + r.plugins = nil +} diff --git a/cmd/entire/cli/plugins/registry_test.go b/cmd/entire/cli/plugins/registry_test.go new file mode 100644 index 0000000000..853e2c189c --- /dev/null +++ b/cmd/entire/cli/plugins/registry_test.go @@ -0,0 +1,266 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/settings" + lua "github.com/yuin/gopher-lua" +) + +// writeUserPlugin creates /lua//{plugin.json,main.lua}. +func writeUserPlugin(t *testing.T, parent, name, manifest, mainLua string) { + t.Helper() + dir := filepath.Join(parent, "lua", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ManifestFileName), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, DefaultEntry), []byte(mainLua), 0o600); err != nil { + t.Fatalf("write main.lua: %v", err) + } +} + +// writeRepoPlugin creates /.entire/plugins//{plugin.json,main.lua}. +func writeRepoPlugin(t *testing.T, root, name, manifest, mainLua string) { + t.Helper() + dir := filepath.Join(RepoLuaPluginsDir(root), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir repo plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ManifestFileName), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, DefaultEntry), []byte(mainLua), 0o600); err != nil { + t.Fatalf("write main.lua: %v", err) + } +} + +const turnEndCounter = ` +_G.fired = 0 +entire.on("turn_end", function(ev) + _G.fired = _G.fired + 1 + _G.last_session = ev.session_id +end) +` + +func TestDiscover_LoadsEnabledUserPlugin_AndFires(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + writeUserPlugin(t, parent, "notify", + `{"name":"notify","hooks":["turn_end"]}`, turnEndCounter) + + // A user-global plugin enabled via the merged team settings (localGrants nil) + // runs, mirroring the external_agents posture — team settings.json may + // enable a user-installed plugin. + s := &settings.EntireSettings{ + Plugins: map[string]settings.PluginSettings{ + "notify": {Enabled: true}, + }, + } + reg := Discover(context.Background(), "", s, nil) + defer reg.Close() + + if reg.Len() != 1 { + t.Fatalf("expected 1 loaded plugin, got %d (%v)", reg.Len(), reg.PluginNames()) + } + p := reg.Plugins()[0] + if !p.HasHook(HookTurnEnd) { + t.Fatal("plugin did not register turn_end hook") + } + + reg.FireObserver(context.Background(), HookTurnEnd, map[string]any{"session_id": "abc123"}) + + if got := lua.LVAsNumber(p.L.GetGlobal("fired")); got != 1 { + t.Errorf("fired = %v, want 1", got) + } + if got := p.L.GetGlobal("last_session").String(); got != "abc123" { + t.Errorf("last_session = %q, want abc123", got) + } +} + +func TestDiscover_SkipsNotAllowlisted(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + writeUserPlugin(t, parent, "notify", `{"name":"notify"}`, turnEndCounter) + + // Empty allow-list: nothing runs. + reg := Discover(context.Background(), "", &settings.EntireSettings{}, nil) + defer reg.Close() + if reg.Len() != 0 { + t.Fatalf("expected 0 plugins for empty allow-list, got %d", reg.Len()) + } +} + +func TestDiscover_SkipsDisabled(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + writeUserPlugin(t, parent, "notify", `{"name":"notify"}`, turnEndCounter) + + s := &settings.EntireSettings{ + Plugins: map[string]settings.PluginSettings{"notify": {Enabled: false}}, + } + reg := Discover(context.Background(), "", s, nil) + defer reg.Close() + if reg.Len() != 0 { + t.Fatalf("expected 0 plugins for disabled entry, got %d", reg.Len()) + } +} + +func TestDiscover_RepoLocalNeverAutoRunsWithoutAllowlist(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + root := t.TempDir() + writeRepoPlugin(t, root, "repoplug", `{"name":"repoplug"}`, turnEndCounter) + + // No allow-list entry anywhere: the repo-local plugin must not load. + reg := Discover(context.Background(), root, &settings.EntireSettings{}, nil) + defer reg.Close() + if reg.Len() != 0 { + t.Fatalf("repo-local plugin auto-ran without allow-list: got %d", reg.Len()) + } +} + +func TestDiscover_RepoLocalIgnoresTeamSettingsEnable(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + root := t.TempDir() + writeRepoPlugin(t, root, "repoplug", `{"name":"repoplug"}`, turnEndCounter) + + // Enabled via committed team settings (merged EntireSettings) but NOT in the + // per-developer local settings: a repo-local plugin must stay inert. This is + // the trust boundary — cloning a hostile repo that ships a plugin plus a team + // settings enable cannot execute the plugin's code. + teamEnabled := &settings.EntireSettings{ + Plugins: map[string]settings.PluginSettings{"repoplug": {Enabled: true}}, + } + reg := Discover(context.Background(), root, teamEnabled, nil) + defer reg.Close() + if reg.Len() != 0 { + t.Fatalf("repo-local plugin ran from team settings alone: got %d", reg.Len()) + } +} + +func TestDiscover_RepoLocalRunsWhenEnabledInLocalSettings(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + root := t.TempDir() + writeRepoPlugin(t, root, "repoplug", `{"name":"repoplug"}`, turnEndCounter) + + // The local developer opted in via .entire/settings.local.json. The merged + // settings therefore also show it enabled, and localGrants carries the same + // entry — the repo-local plugin may now run. + merged := &settings.EntireSettings{ + Plugins: map[string]settings.PluginSettings{"repoplug": {Enabled: true}}, + } + localGrants := map[string]settings.PluginSettings{"repoplug": {Enabled: true}} + reg := Discover(context.Background(), root, merged, localGrants) + defer reg.Close() + if reg.Len() != 1 { + t.Fatalf("expected repo-local plugin to load when enabled in local settings, got %d", reg.Len()) + } + if got := reg.Plugins()[0].Source; got != SourceRepo { + t.Errorf("expected repo source, got %q", got) + } +} + +func TestDiscover_UserWinsNameCollision(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + root := t.TempDir() + + writeUserPlugin(t, parent, "dup", `{"name":"dup"}`, `_G.origin = "user"`+"\n"+turnEndCounter) + writeRepoPlugin(t, root, "dup", `{"name":"dup"}`, `_G.origin = "repo"`+"\n"+turnEndCounter) + + s := &settings.EntireSettings{ + Plugins: map[string]settings.PluginSettings{"dup": {Enabled: true}}, + } + // Enable the repo copy locally too, so if precedence somehow flipped the + // repo-local plugin would be eligible; the user copy must still win. + localGrants := map[string]settings.PluginSettings{"dup": {Enabled: true}} + reg := Discover(context.Background(), root, s, localGrants) + defer reg.Close() + + if reg.Len() != 1 { + t.Fatalf("expected 1 plugin after de-dup, got %d", reg.Len()) + } + p := reg.Plugins()[0] + if p.Source != SourceUser { + t.Errorf("expected user plugin to win collision, got source %q", p.Source) + } + if got := p.L.GetGlobal("origin").String(); got != "user" { + t.Errorf("origin = %q, want user", got) + } +} + +func TestFireObserver_ErrorIsNonFatal(t *testing.T) { + parent := t.TempDir() + t.Setenv("ENTIRE_PLUGIN_DIR", parent) + writeUserPlugin(t, parent, "boom", + `{"name":"boom","hooks":["turn_end"]}`, + `entire.on("turn_end", function() error("boom") end)`) + + s := &settings.EntireSettings{ + Plugins: map[string]settings.PluginSettings{"boom": {Enabled: true}}, + } + reg := Discover(context.Background(), "", s, nil) + defer reg.Close() + + // Must not panic or propagate; the error is swallowed and logged. + reg.FireObserver(context.Background(), HookTurnEnd, map[string]any{}) +} + +func TestFireObserver_PerHookTimeout(t *testing.T) { + L := NewSandboxedState(context.Background()) + fn := L.NewFunction(func(l *lua.LState) int { + _ = l.DoString(`while true do end`) //nolint:errcheck // intentional runaway; the per-hook context timeout aborts it + return 0 + }) + p := &LoadedPlugin{ + Manifest: Manifest{Name: "slow"}, + Source: SourceUser, + L: L, + callbacks: map[string][]*lua.LFunction{HookTurnEnd: {fn}}, + } + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + start := time.Now() + reg.FireObserver(context.Background(), HookTurnEnd, map[string]any{}) + if elapsed := time.Since(start); elapsed > observerHookTimeout+3*time.Second { + t.Fatalf("observer timeout not enforced: %v", elapsed) + } +} + +func TestFireObserver_LatencyGate(t *testing.T) { + L := NewSandboxedState(context.Background()) + fn := L.NewFunction(func(*lua.LState) int { return 0 }) + p := &LoadedPlugin{ + Manifest: Manifest{Name: "noop"}, + Source: SourceUser, + L: L, + callbacks: map[string][]*lua.LFunction{HookTurnEnd: {fn}}, + } + reg := &Registry{} + reg.Add(p) + defer reg.Close() + + ctx := context.Background() + payload := map[string]any{"session_id": "s1", "model": "test"} + const n = 1000 + start := time.Now() + for range n { + reg.FireObserver(ctx, HookTurnEnd, payload) + } + per := time.Since(start) / n + t.Logf("empty observer hook dispatch: %v/op", per) + if per > time.Millisecond { + t.Errorf("empty hook dispatch too slow: %v/op (gate: <1ms)", per) + } +} diff --git a/cmd/entire/cli/plugins/sandbox.go b/cmd/entire/cli/plugins/sandbox.go new file mode 100644 index 0000000000..f65b2bbb52 --- /dev/null +++ b/cmd/entire/cli/plugins/sandbox.go @@ -0,0 +1,81 @@ +package plugins + +import ( + "context" + + lua "github.com/yuin/gopher-lua" +) + +// Sandbox tuning. The call-stack and registry sizes bound a runaway plugin's +// memory before the context deadline trips. They are deliberately modest: +// plugins are event callbacks, not long-running programs. +const ( + sandboxCallStackSize = 120 + sandboxRegistrySize = 1024 * 8 +) + +// curatedLibs is the allow-list of standard libraries opened in a sandboxed +// state: base, string, table, and math only. os, io, package (require), debug, +// coroutine, and channel are intentionally omitted — they are the escape +// hatches to the host filesystem, process, and module loader. +var curatedLibs = []struct { + name string + open lua.LGFunction +}{ + {lua.BaseLibName, lua.OpenBase}, + {lua.TabLibName, lua.OpenTable}, + {lua.StringLibName, lua.OpenString}, + {lua.MathLibName, lua.OpenMath}, +} + +// strippedBaseGlobals are base-library globals removed after OpenBase runs. +// They can load bytecode/source or reach the host outside the curated set, so +// they are unset even though os/io/package were never opened (dofile/loadfile +// use Go's os package directly regardless of the io/os Lua libs). print is +// stripped here and re-provided by the API layer so it routes to the plugin +// logger instead of the process stdout, which would corrupt hook output. +var strippedBaseGlobals = []string{ + "dofile", + "loadfile", + "load", + "loadstring", + "require", + "module", + "collectgarbage", + "newproxy", + "print", + "_printregs", +} + +// NewSandboxedState builds a Lua state with only the curated standard library +// and the host escape hatches removed. When ctx is non-nil it is attached via +// SetContext, so a context deadline/cancellation aborts a running script +// between VM instructions (the per-hook timeout mechanism). +// +// The returned state does NOT yet expose the `entire` module; callers layer +// that on via the API installer so it can capture per-plugin state. +func NewSandboxedState(ctx context.Context) *lua.LState { + L := lua.NewState(lua.Options{ + SkipOpenLibs: true, + CallStackSize: sandboxCallStackSize, + RegistrySize: sandboxRegistrySize, + IncludeGoStackTrace: false, + }) + + // Open only the curated libraries. Each open function is called as a Lua + // function with the library name argument, mirroring linit.go's loader. + for _, lib := range curatedLibs { + L.Push(L.NewFunction(lib.open)) + L.Push(lua.LString(lib.name)) + L.Call(1, 0) + } + + for _, name := range strippedBaseGlobals { + L.SetGlobal(name, lua.LNil) + } + + if ctx != nil { + L.SetContext(ctx) + } + return L +} diff --git a/cmd/entire/cli/plugins/sandbox_test.go b/cmd/entire/cli/plugins/sandbox_test.go new file mode 100644 index 0000000000..69e4e73206 --- /dev/null +++ b/cmd/entire/cli/plugins/sandbox_test.go @@ -0,0 +1,104 @@ +package plugins + +import ( + "context" + "testing" + "time" + + lua "github.com/yuin/gopher-lua" +) + +func TestNewSandboxedState_CuratedLibsPresent(t *testing.T) { + t.Parallel() + L := NewSandboxedState(context.Background()) + defer L.Close() + + // base, string, table, math must be usable. + scripts := []string{ + `assert(type(tostring) == "function")`, + `assert(("abc"):upper() == "ABC")`, + `assert(type(string.format) == "function")`, + `assert(type(table.insert) == "function")`, + `assert(math.floor(1.9) == 1)`, + } + for _, s := range scripts { + if err := L.DoString(s); err != nil { + t.Fatalf("curated lib script failed: %q: %v", s, err) + } + } +} + +func TestNewSandboxedState_EscapeHatchesRemoved(t *testing.T) { + t.Parallel() + L := NewSandboxedState(context.Background()) + defer L.Close() + + // os and io libs are never opened; the escape-hatch base globals are unset. + removed := []string{ + "os", "io", "package", "require", "dofile", "loadfile", + "load", "loadstring", "module", "collectgarbage", "newproxy", "debug", + } + for _, name := range removed { + if err := L.DoString("assert(" + name + " == nil)"); err != nil { + t.Errorf("expected global %q to be nil in sandbox: %v", name, err) + } + } +} + +func TestNewSandboxedState_ContextTimeoutAbortsRunaway(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + L := NewSandboxedState(ctx) + defer L.Close() + + start := time.Now() + err := L.DoString(`while true do end`) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected infinite loop to be aborted by context timeout, got nil error") + } + if elapsed > 2*time.Second { + t.Fatalf("context timeout took too long to abort: %v", elapsed) + } +} + +func TestNewSandboxedState_BackgroundContextOK(t *testing.T) { + t.Parallel() + L := NewSandboxedState(context.Background()) + defer L.Close() + if err := L.DoString(`return 1 + 1`); err != nil { + t.Fatalf("background-context sandbox should run: %v", err) + } +} + +func BenchmarkFireObserverEmptyHook(b *testing.B) { + // Measures dispatch overhead for a registered but trivial callback — the + // decision-gate latency number for wiring hooks into the commit path. + L := NewSandboxedState(context.Background()) + defer L.Close() + var called int + fn := L.NewFunction(func(*lua.LState) int { called++; return 0 }) + + p := &LoadedPlugin{ + Manifest: Manifest{Name: "bench"}, + Source: SourceUser, + L: L, + callbacks: map[string][]*lua.LFunction{HookTurnEnd: {fn}}, + } + r := &Registry{} + r.Add(p) + + ctx := context.Background() + payload := map[string]any{"session_id": "s1"} + b.ResetTimer() + for range b.N { + r.FireObserver(ctx, HookTurnEnd, payload) + } + b.StopTimer() + if called == 0 { + b.Fatal("callback never fired") + } +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 65cba82a30..5833293807 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -15,6 +15,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "time" @@ -124,6 +125,14 @@ type EntireSettings struct { // plugins (entire-agent-* binaries on $PATH). Defaults to false. ExternalAgents bool `json:"external_agents,omitempty"` + // Plugins is the allow-list of Lua plugins keyed by plugin name, carrying + // per-plugin capability grants. It mirrors the external_agents opt-in + // posture: a Lua plugin (whether installed under the per-user managed dir + // or discovered as repo-local under .entire/plugins) is inert until it has + // an entry here with enabled=true. Repo-local plugins NEVER auto-run + // without such an explicit entry — see docs/architecture/plugins-lua.md. + Plugins map[string]PluginSettings `json:"plugins,omitempty"` + // SummaryGeneration stores provider preferences for explain --generate. // This is separate from strategy_options.summarize, which controls // checkpoint auto-summarize behavior. @@ -433,6 +442,156 @@ func (s *EntireSettings) InvestigateConfig() *InvestigateConfig { return s.Investigate } +// Plugin capability names. These gate the privileged Lua APIs a plugin may +// call. A capability not granted in the plugin's allow-list entry causes the +// corresponding Lua API to error at call time rather than silently no-op. +// +// The names are defined here (rather than in the plugins package) so the strict +// settings validator can reject typos at parse time without the settings +// package importing plugins (which would create an import cycle: plugins already +// imports settings). +const ( + // PluginCapabilityHTTP grants outbound HTTP requests (entire.http). + PluginCapabilityHTTP = "http" + // PluginCapabilityExec grants running external subprocesses (entire.exec). + PluginCapabilityExec = "exec" + // PluginCapabilityFS grants filesystem reads/writes outside the plugin + // data dir (entire.fs). + PluginCapabilityFS = "fs" + // PluginCapabilityNet grants raw network access (entire.net). + PluginCapabilityNet = "net" + // PluginCapabilityCommitMsg lets a plugin's prepare_commit_msg hook append + // a trailer to the user's commit message. + PluginCapabilityCommitMsg = "commit_msg" + // PluginCapabilityPrePush lets a plugin's pre_push hook veto a push + // (aborting it with a non-zero hook exit). + PluginCapabilityPrePush = "pre_push" +) + +// PluginSettings configures a single Lua plugin's activation and the +// capabilities granted to it. It mirrors the external_agents opt-in posture: +// the plugin is inert until Enabled is true, and privileged APIs stay denied +// unless explicitly listed in Capabilities. +type PluginSettings struct { + // Enabled activates the plugin. Defaults to false: an absent entry or one + // with enabled=false means the plugin's hooks and commands never run. + Enabled bool `json:"enabled"` + + // Capabilities grants privileged Lua APIs (http, exec, fs, net). Any + // capability not listed here is denied. Unknown capability names are + // rejected at settings load time. + Capabilities []string `json:"capabilities,omitempty"` +} + +// HasCapability reports whether the plugin was granted the named capability. +func (p PluginSettings) HasCapability(name string) bool { + return slices.Contains(p.Capabilities, name) +} + +// PluginGrant returns the allow-list entry for the named plugin and whether an +// entry exists for it. A missing entry (ok=false) means the plugin is not +// allow-listed and must not run. +func (s *EntireSettings) PluginGrant(name string) (PluginSettings, bool) { + if s == nil || s.Plugins == nil { + return PluginSettings{}, false + } + ps, ok := s.Plugins[name] + return ps, ok +} + +// IsPluginEnabled reports whether the named plugin is allow-listed and enabled. +func (s *EntireSettings) IsPluginEnabled(name string) bool { + ps, ok := s.PluginGrant(name) + return ok && ps.Enabled +} + +// LocalPluginGrants returns the Lua plugin allow-list entries defined ONLY in +// the per-developer .entire/settings.local.json file, ignoring the committed +// team .entire/settings.json entirely. +// +// This is the trust boundary that keeps cloning a hostile repo safe. Repo-local +// plugins (.entire/plugins/) may be activated only from this personal, +// uncommitted scope: a malicious PR can ship both a .entire/plugins/ +// directory and a team .entire/settings.json that sets +// plugins..enabled = true, but because that enable lives in committed team +// settings it must NOT count for a repo-local plugin — only a grant the local +// developer wrote in settings.local.json does. User-global installed plugins +// (under the managed lua/ dir) are unaffected and may still be allow-listed from +// either file via the merged settings. +// +// Returns an empty (non-nil) map when the local file is absent or defines no +// plugins. Capability grants are validated so a typo in the local file fails +// loud, mirroring the merged-settings load path. +func LocalPluginGrants(ctx context.Context) (map[string]PluginSettings, error) { + _, raw, exists, err := LoadLocalRaw(ctx) + if err != nil { + return nil, err + } + if !exists { + return map[string]PluginSettings{}, nil + } + pluginsRaw, ok := raw["plugins"] + if !ok { + return map[string]PluginSettings{}, nil + } + var grants map[string]PluginSettings + if err := json.Unmarshal(pluginsRaw, &grants); err != nil { + return nil, fmt.Errorf("parsing local plugins: %w", err) + } + if err := validatePluginSettings(grants); err != nil { + return nil, err + } + if grants == nil { + return map[string]PluginSettings{}, nil + } + return grants, nil +} + +// isKnownPluginCapability reports whether name is a recognized capability. +func isKnownPluginCapability(name string) bool { + switch name { + case PluginCapabilityHTTP, PluginCapabilityExec, PluginCapabilityFS, PluginCapabilityNet, + PluginCapabilityCommitMsg, PluginCapabilityPrePush: + return true + default: + return false + } +} + +// IsKnownPluginCapabilityName reports whether name is a recognized plugin +// capability. Exported for the plugins package's manifest validator so both the +// settings allow-list and the manifest reject the same typos. +func IsKnownPluginCapabilityName(name string) bool { + return isKnownPluginCapability(name) +} + +// KnownPluginCapabilities returns the recognized capability names in a stable +// order, for use in validation error messages and manifest validation. +func KnownPluginCapabilities() []string { + return []string{ + PluginCapabilityHTTP, PluginCapabilityExec, PluginCapabilityFS, PluginCapabilityNet, + PluginCapabilityCommitMsg, PluginCapabilityPrePush, + } +} + +// validatePluginSettings rejects empty plugin names and unknown capability +// grants so typos surface at settings load time rather than as a silently +// ignored (and therefore never-granted) capability at runtime. +func validatePluginSettings(plugins map[string]PluginSettings) error { + for name, ps := range plugins { + if strings.TrimSpace(name) == "" { + return errors.New("plugins: empty plugin name") + } + for _, capName := range ps.Capabilities { + if !isKnownPluginCapability(capName) { + return fmt.Errorf("plugins.%s.capabilities has unknown capability %q (allowed: %s)", + name, capName, strings.Join(KnownPluginCapabilities(), ", ")) + } + } + } + return nil +} + // Load loads the Entire settings from .entire/settings.json, then applies // clone-local preferences from the git common dir, then applies any overrides // from .entire/settings.local.json if it exists. @@ -543,6 +702,12 @@ func loadMergedSettings(settingsFileAbs, preferencesFileAbs, localSettingsFileAb return nil, fmt.Errorf("merged settings invalid: %w", err) } + // Re-validate plugin capability grants after merge: a local override can + // add a plugins entry (or capabilities) neither file alone contained. + if err := validatePluginSettings(settings.Plugins); err != nil { + return nil, fmt.Errorf("merged settings invalid: %w", err) + } + return settings, nil } @@ -686,6 +851,9 @@ func LoadFromBytes(data []byte) (*EntireSettings, error) { return nil, err } } + if err := validatePluginSettings(s.Plugins); err != nil { + return nil, err + } return s, nil } @@ -752,6 +920,10 @@ func loadFromFile(filePath string) (*EntireSettings, error) { } } + if err := validatePluginSettings(settings.Plugins); err != nil { + return nil, err + } + return settings, nil } @@ -926,6 +1098,35 @@ func mergeJSON(settings *EntireSettings, data []byte) error { return err } + if err := mergePlugins(settings, raw); err != nil { + return err + } + + return nil +} + +// mergePlugins overlays the override's plugins map onto the base by plugin +// name: an entry from the higher-precedence layer replaces the same-named base +// entry wholesale, while entries unique to each layer are preserved. This lets +// a team allow-list plugins in .entire/settings.json while individuals enable +// or grant extra capabilities in settings.local.json without either layer +// hiding the other's plugins. +func mergePlugins(settings *EntireSettings, raw map[string]json.RawMessage) error { + pluginsRaw, ok := raw["plugins"] + if !ok { + return nil + } + var plugins map[string]PluginSettings + if err := unmarshalField("plugins", pluginsRaw, &plugins); err != nil { + return err + } + if settings.Plugins == nil { + settings.Plugins = plugins + return nil + } + for name, ps := range plugins { + settings.Plugins[name] = ps + } return nil } diff --git a/cmd/entire/cli/settings/settings_plugins_test.go b/cmd/entire/cli/settings/settings_plugins_test.go new file mode 100644 index 0000000000..8ab593baf9 --- /dev/null +++ b/cmd/entire/cli/settings/settings_plugins_test.go @@ -0,0 +1,144 @@ +package settings + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// writePluginTestRepo creates a temp git repo with optional team +// (.entire/settings.json) and local (.entire/settings.local.json) contents and +// chdir's into it. Empty content skips that file. +func writePluginTestRepo(t *testing.T, team, local string) { + t.Helper() + dir := t.TempDir() + entireDir := filepath.Join(dir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatalf("mkdir .entire: %v", err) + } + if team != "" { + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(team), 0o600); err != nil { + t.Fatalf("write settings.json: %v", err) + } + } + if local != "" { + if err := os.WriteFile(filepath.Join(entireDir, "settings.local.json"), []byte(local), 0o600); err != nil { + t.Fatalf("write settings.local.json: %v", err) + } + } + // paths.AbsPath resolves relative to the git worktree root. + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + t.Chdir(dir) +} + +func TestLocalPluginGrants_OnlyReadsLocalFile(t *testing.T) { + // A repo-local plugin enabled solely in committed team settings must NOT + // appear in the local grants; only settings.local.json entries do. + team := `{"plugins": {"teamplug": {"enabled": true}}}` + local := `{"plugins": {"localplug": {"enabled": true, "capabilities": ["exec"]}}}` + writePluginTestRepo(t, team, local) + + grants, err := LocalPluginGrants(context.Background()) + if err != nil { + t.Fatalf("LocalPluginGrants() error = %v", err) + } + if _, ok := grants["teamplug"]; ok { + t.Error("team-settings plugin must not be reported as a local grant") + } + g, ok := grants["localplug"] + if !ok || !g.Enabled { + t.Fatalf("expected localplug enabled in local grants, got %+v", grants) + } + if !g.HasCapability(PluginCapabilityExec) { + t.Errorf("localplug missing exec capability: %+v", g.Capabilities) + } +} + +func TestLocalPluginGrants_AbsentFileIsEmpty(t *testing.T) { + writePluginTestRepo(t, `{"plugins": {"teamplug": {"enabled": true}}}`, "") + + grants, err := LocalPluginGrants(context.Background()) + if err != nil { + t.Fatalf("LocalPluginGrants() error = %v", err) + } + if len(grants) != 0 { + t.Errorf("expected no local grants when settings.local.json absent, got %+v", grants) + } +} + +func TestLocalPluginGrants_RejectsUnknownCapability(t *testing.T) { + writePluginTestRepo(t, "", `{"plugins": {"bad": {"enabled": true, "capabilities": ["telepathy"]}}}`) + + _, err := LocalPluginGrants(context.Background()) + if err == nil || !strings.Contains(err.Error(), "unknown capability") { + t.Fatalf("expected unknown capability error, got %v", err) + } +} + +func TestLoadFromBytes_PluginsAllowlist(t *testing.T) { + t.Parallel() + data := []byte(`{ + "plugins": { + "notify": {"enabled": true, "capabilities": ["http", "fs"]}, + "linter": {"enabled": false} + } + }`) + s, err := LoadFromBytes(data) + if err != nil { + t.Fatalf("LoadFromBytes() error = %v", err) + } + if !s.IsPluginEnabled("notify") { + t.Error("notify should be enabled") + } + if s.IsPluginEnabled("linter") { + t.Error("linter should be disabled") + } + if s.IsPluginEnabled("absent") { + t.Error("absent plugin should not be enabled") + } + + grant, ok := s.PluginGrant("notify") + if !ok { + t.Fatal("expected notify grant present") + } + if !grant.HasCapability(PluginCapabilityHTTP) || !grant.HasCapability(PluginCapabilityFS) { + t.Errorf("notify missing capabilities: %+v", grant.Capabilities) + } + if grant.HasCapability(PluginCapabilityExec) { + t.Error("notify should not have exec capability") + } +} + +func TestLoadFromBytes_RejectsUnknownCapability(t *testing.T) { + t.Parallel() + data := []byte(`{"plugins": {"bad": {"enabled": true, "capabilities": ["telepathy"]}}}`) + _, err := LoadFromBytes(data) + if err == nil || !strings.Contains(err.Error(), "unknown capability") { + t.Fatalf("expected unknown capability error, got %v", err) + } +} + +func TestLoadFromBytes_RejectsUnknownPluginField(t *testing.T) { + t.Parallel() + // DisallowUnknownFields must reject a typo'd PluginSettings field. + data := []byte(`{"plugins": {"p": {"enable": true}}}`) + _, err := LoadFromBytes(data) + if err == nil { + t.Fatal("expected error for unknown plugin field 'enable'") + } +} + +func TestPluginGrant_NilSafe(t *testing.T) { + t.Parallel() + var s *EntireSettings + if _, ok := s.PluginGrant("x"); ok { + t.Error("nil settings should report no grant") + } + if s.IsPluginEnabled("x") { + t.Error("nil settings should report plugin disabled") + } +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 85571be051..0ed194a313 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -354,7 +354,7 @@ func isGitSequenceOperation(ctx context.Context) bool { // - "commit": amend operation - preserves existing trailer or restores from LastCheckpointID // -func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFile string, source string) error { +func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFile string, source string) (err error) { logCtx := logging.WithComponent(ctx, "checkpoint") // Skip during rebase, cherry-pick, or revert operations @@ -378,6 +378,17 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFi return nil } + // After the built-in trailer handling below completes successfully, let + // commit_msg-capable plugins append their own trailers. Deferred so it runs + // on every non-sequence, non-merge/squash return path (normal and amend) + // without threading the call through each branch. Plugin trailers land after + // the Entire-Checkpoint trailer, never displacing it. + defer func() { + if err == nil { + appendPluginCommitTrailers(ctx, commitMsgFile, source) + } + }() + // Handle amend (source="commit") separately: preserve or restore trailer if source == "commit" { return s.handleAmendCommitMsg(ctx, commitMsgFile) @@ -907,6 +918,14 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { checkpointID, found := trailers.ParseCheckpoint(commit.Message) openRepoSpan.End() + // Observer hook: a commit landed. Fired for every commit (with or without a + // checkpoint trailer) so plugins can react to the user's commit. + cpIDStr := "" + if found { + cpIDStr = checkpointID.String() + } + firePluginPostCommit(ctx, commit.Hash.String(), cpIDStr, found) + if !found { // No trailer — user removed it or it was never added (mid-turn commit). // Still update BaseCommit for active sessions so future commits can match. diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index 86e3e3030f..debf277907 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -43,6 +43,14 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error ps := resolvePushSettings(resolveCtx, remote) resolveSpan.End() + // Plugin pre_push hook: observer side effects for all enabled plugins, plus + // a veto for plugins granted the pre_push capability. Runs before the OPF + // rewrite and checkpoint-ref push so a veto short-circuits that work. A veto + // aborts the user's push via a non-zero hook exit. + if vetoErr := firePluginPrePush(ctx, remote, ps.pushTarget()); vetoErr != nil { + return vetoErr + } + if ps.pushDisabled { return nil } diff --git a/cmd/entire/cli/strategy/plugin_hooks.go b/cmd/entire/cli/strategy/plugin_hooks.go new file mode 100644 index 0000000000..4cefe921a0 --- /dev/null +++ b/cmd/entire/cli/strategy/plugin_hooks.go @@ -0,0 +1,98 @@ +package strategy + +import ( + "context" + "log/slog" + "os" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/plugins" +) + +// firePluginPostCommit dispatches the post_commit observer hook after a commit +// is processed by the strategy. Best-effort: a no-op when no plugin is enabled +// and never propagates plugin failures into the git hook. +func firePluginPostCommit(ctx context.Context, commitSHA, checkpointID string, hasCheckpoint bool) { + payload := map[string]any{ + "commit": commitSHA, + "has_checkpoint": hasCheckpoint, + } + if checkpointID != "" { + payload["checkpoint_id"] = checkpointID + } + plugins.FireHook(ctx, plugins.HookPostCommit, payload) +} + +// firePluginPrePush dispatches the pre_push hook (observer side effects for all +// enabled plugins, plus a veto for plugins granted the pre_push capability). It +// returns a non-nil error when a capable plugin vetoes the push; the caller +// propagates that error so the git pre-push hook exits non-zero and the push is +// aborted. Runs before the built-in OPF rewrite and checkpoint-ref push so a +// veto short-circuits that work. +func firePluginPrePush(ctx context.Context, remote, pushTarget string) error { + payload := map[string]any{"remote": remote} + if pushTarget != "" { + payload["push_target"] = pushTarget + } + //nolint:wrapcheck // the veto error is already user-facing ("push vetoed by plugin ..."); the pre-push hook adds its own "pre-push:" prefix, so wrapping here would only duplicate context + return plugins.FirePrePush(ctx, payload) +} + +// appendPluginCommitTrailers appends trailer lines contributed by +// prepare_commit_msg plugins (those granted the commit_msg capability) to the +// commit message file. It runs after the strategy's own trailer handling so the +// built-in Entire-Checkpoint trailer is never displaced. Best-effort: any read +// or write failure is logged and skipped so it never blocks the commit. +func appendPluginCommitTrailers(ctx context.Context, commitMsgFile, source string) { + trailers := plugins.FireCommitMsg(ctx, map[string]any{"source": source}) + if len(trailers) == 0 { + return + } + content, err := os.ReadFile(commitMsgFile) //nolint:gosec // commitMsgFile is provided by the git prepare-commit-msg hook + if err != nil { + logging.Warn(logging.WithComponent(ctx, "plugins"), "prepare_commit_msg: read commit message failed", + slog.String("error", err.Error())) + return + } + updated := insertCommitTrailers(string(content), trailers) + //nolint:gosec // commitMsgFile is the path passed by the git prepare-commit-msg hook, not user-tainted input + if err := os.WriteFile(commitMsgFile, []byte(updated), 0o600); err != nil { + logging.Warn(logging.WithComponent(ctx, "plugins"), "prepare_commit_msg: write commit message failed", + slog.String("error", err.Error())) + } +} + +// insertCommitTrailers inserts trailer lines before the trailing git comment +// block (lines starting with '#'), or at the end when there is no comment +// block. Each trailer may itself contain newlines; blank/whitespace-only +// trailers are dropped. +func insertCommitTrailers(msg string, trailers []string) string { + var lines []string + for _, t := range trailers { + for _, sub := range strings.Split(strings.TrimRight(t, "\n"), "\n") { + if strings.TrimSpace(sub) == "" { + continue + } + lines = append(lines, sub) + } + } + if len(lines) == 0 { + return msg + } + + existing := strings.Split(msg, "\n") + insertAt := len(existing) + for i, ln := range existing { + if strings.HasPrefix(strings.TrimSpace(ln), "#") { + insertAt = i + break + } + } + + out := make([]string, 0, len(existing)+len(lines)) + out = append(out, existing[:insertAt]...) + out = append(out, lines...) + out = append(out, existing[insertAt:]...) + return strings.Join(out, "\n") +} diff --git a/cmd/entire/cli/strategy/plugin_hooks_test.go b/cmd/entire/cli/strategy/plugin_hooks_test.go new file mode 100644 index 0000000000..a1b2ca52a7 --- /dev/null +++ b/cmd/entire/cli/strategy/plugin_hooks_test.go @@ -0,0 +1,60 @@ +package strategy + +import ( + "strings" + "testing" +) + +func TestInsertCommitTrailers_BeforeCommentBlock(t *testing.T) { + t.Parallel() + msg := "my subject\n\nbody line\n\nEntire-Checkpoint: abc123\n# Please enter the commit message\n# with comments\n" + out := insertCommitTrailers(msg, []string{"Plugin-Trailer: yes"}) + + if !strings.Contains(out, "Plugin-Trailer: yes") { + t.Fatalf("trailer not inserted: %q", out) + } + // Trailer must appear before the comment block. + trailerIdx := strings.Index(out, "Plugin-Trailer: yes") + commentIdx := strings.Index(out, "# Please enter") + if trailerIdx == -1 || commentIdx == -1 || trailerIdx > commentIdx { + t.Fatalf("trailer must precede comment block:\n%s", out) + } + // The built-in Entire-Checkpoint trailer must be preserved and precede the + // plugin trailer (built-in is never displaced). + entireIdx := strings.Index(out, "Entire-Checkpoint: abc123") + if entireIdx == -1 || entireIdx > trailerIdx { + t.Fatalf("Entire-Checkpoint must precede plugin trailer:\n%s", out) + } +} + +func TestInsertCommitTrailers_NoCommentBlock(t *testing.T) { + t.Parallel() + msg := "subject\n\nbody\n" + out := insertCommitTrailers(msg, []string{"A: 1", "B: 2"}) + if !strings.Contains(out, "A: 1") || !strings.Contains(out, "B: 2") { + t.Fatalf("trailers not appended: %q", out) + } + if strings.Index(out, "A: 1") > strings.Index(out, "B: 2") { + t.Fatalf("trailers out of order: %q", out) + } +} + +func TestInsertCommitTrailers_DropsBlankTrailers(t *testing.T) { + t.Parallel() + msg := "subject\n" + out := insertCommitTrailers(msg, []string{" ", "\n", "Real: 1"}) + if strings.Count(out, "\n") > 3 { + t.Fatalf("blank trailers should be dropped: %q", out) + } + if !strings.Contains(out, "Real: 1") { + t.Fatalf("real trailer dropped: %q", out) + } +} + +func TestInsertCommitTrailers_EmptyReturnsUnchanged(t *testing.T) { + t.Parallel() + msg := "subject\n" + if out := insertCommitTrailers(msg, nil); out != msg { + t.Fatalf("expected unchanged message, got %q", out) + } +} diff --git a/cmd/entire/main.go b/cmd/entire/main.go index a658e7c923..9a0ddcdee9 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -69,6 +69,14 @@ func main() { // inherits the prepended PATH so it can spawn sibling managed plugins. restorePATH := cli.PrependPluginBinDirToPATH(ctx) + // Resolution order: built-in > Lua plugin command > entire- binary. + // Lua commands are dispatched before the binary dispatcher so a Lua command + // wins over a same-named binary plugin; both defer to built-ins. + if handled, code := cli.MaybeRunLuaCommand(ctx, rootCmd, os.Args[1:]); handled { + cancel() + os.Exit(code) + } + if handled, code := cli.MaybeRunPlugin(ctx, rootCmd, os.Args[1:]); handled { cancel() os.Exit(code) diff --git a/docs/architecture/plugins-lua.md b/docs/architecture/plugins-lua.md new file mode 100644 index 0000000000..0b386f3436 --- /dev/null +++ b/docs/architecture/plugins-lua.md @@ -0,0 +1,249 @@ +# Lua Plugins + +Entire supports two kinds of third-party plugins: + +1. **Binary plugins** — kubectl-style `entire-` executables on `$PATH` (see + [External Commands](external-commands.md)). Any language, but require a build + and a release pipeline. +2. **Lua plugins** — no-build-step scripts that subscribe to lifecycle/git hooks + and contribute commands, run inside an embedded, sandboxed Lua interpreter. + +This document covers Lua plugins. They are layered on top of the binary plugin +mechanism without replacing it: both are discovered and dispatched, with a +defined precedence. + +The runtime is pure Go ([`github.com/yuin/gopher-lua`](https://github.com/yuin/gopher-lua)), +so it builds with `CGO_ENABLED=0` on every target — no native Lua dependency. + +## Trust model (read this first) + +Lua plugins execute arbitrary code in your `entire` process. The security model +is **opt-in at every step**: + +- A plugin is **inert until allow-listed**. Discovery finds plugins in the + managed user dir and in the repo-local `.entire/plugins/`, but none run unless + there is an entry for it under `plugins` with `"enabled": true`. +- **Repo-local plugins can only be enabled from your personal, uncommitted + `.entire/settings.local.json`** — never from the committed team + `.entire/settings.json`. A repo can ship a `.entire/plugins/foo` directory + *and* a team `.entire/settings.json` with `plugins.foo.enabled = true`, but + that team entry does **not** activate a repo-local plugin. It stays inert until + *you* add `plugins.foo.enabled = true` (and any capabilities) to your own + `.entire/settings.local.json`. This is what makes cloning a hostile repo safe: + a malicious PR cannot ship both the plugin and the enable and have it run on + clone. (User-global installed plugins under the managed `lua/` dir are + unaffected — they may be enabled from either file.) +- **Privileged APIs are capability-gated.** Network, subprocess, filesystem, and + the mutating hooks are denied unless the capability is granted in the plugin's + allow-list entry. An ungranted call raises a Lua error (fail loud, never a + silent no-op). +- **Installing ≠ running.** `entire plugin install ` only places files. + The cloned code cannot run until you complete the allow-list step above. +- **Kill switch.** `ENTIRE_PLUGINS_DISABLED=1` disables all plugins process-wide + regardless of settings. + +The allow-list plus capability grants are the trust boundary. The sandbox +(curated stdlib, stripped escape hatches, execution timeouts) limits *accidental* +damage and blast radius; it does not claim to fully contain a deliberately +malicious plugin you have explicitly enabled. Treat enabling a plugin like +running its code — because that is what it is. + +## Layout + +Managed (installed) Lua plugins live one directory per plugin under the managed +plugin tree, a sibling of the binary store's `bin/` and `data/` dirs: + +``` +~/.local/share/entire/plugins/lua// # installed Lua plugins + plugin.json + main.lua +~/.local/share/entire/plugins/data// # per-plugin durable kv storage +.entire/plugins// # repo-local (never auto-runs) +``` + +The parent dir honors `ENTIRE_PLUGIN_DIR` (absolute override), then +`XDG_DATA_HOME` on Unix / `LOCALAPPDATA` on Windows, then the platform default — +identical resolution to the binary store. + +## Manifest (`plugin.json`) + +Parsed strictly: unknown keys are rejected so typos surface at load time. + +```json +{ + "name": "checkpoint-notify", + "version": "1.0.0", + "description": "Desktop notification on each checkpoint", + "entry": "main.lua", + "hooks": ["checkpoint_saved", "post_commit"], + "commands": [{ "name": "notify-test", "short": "send a test notification" }], + "capabilities": ["exec"] +} +``` + +| Field | Meaning | +| -------------- | ------------------------------------------------------------------ | +| `name` | Required. Dispatch-safe identifier (keys the allow-list/data dir). | +| `version` | Informational. | +| `description` | Shown by `entire plugin list`. | +| `entry` | Entry script, default `main.lua`. Bare file name in the dir. | +| `hooks` | Declared hooks (validated). Actual subscription is via `entire.on`. | +| `commands` | Declared commands. Actual registration is via `entire.command`. | +| `capabilities` | Requested capabilities (only take effect when also granted). | + +The manifest documents intent; the settings allow-list grants power. + +## Settings allow-list + +Mirrors the `external_agents` opt-in posture. + +```json +{ + "plugins": { + "checkpoint-notify": { "enabled": true, "capabilities": ["exec"] }, + "models-updater": { "enabled": true, "capabilities": ["http", "fs"] } + } +} +``` + +- `enabled` — the plugin runs only when `true`. +- `capabilities` — granted capabilities. Unknown names are rejected at load. + Valid: `http`, `exec`, `fs`, `net`, `commit_msg`, `pre_push`. + +Team settings (`.entire/settings.json`) and per-developer overrides +(`.entire/settings.local.json`) merge per-plugin: an override can enable or grant +extra capabilities without hiding the team's entries. + +**Scope matters for repo-local plugins.** Because `.entire/settings.json` is +committed, a hostile repo could otherwise ship both a `.entire/plugins/` +directory and a team entry enabling it. To prevent that, a **repo-local plugin +is governed only by `.entire/settings.local.json`** (which is not committed): its +`enabled` flag and capabilities must come from that personal file, and any entry +for it in the committed team `.entire/settings.json` is ignored for activation. +User-global plugins installed under the managed `lua/` dir are governed by the +merged allow-list and may be enabled from either file. + +## The `entire` Lua API + +The entry script runs once at load and registers hooks/commands. See the +annotated stub in [`examples/plugins/entire.lua`](../../examples/plugins/entire.lua) +for editor autocompletion. + +### Always available + +- `entire.on(hook, function(event) ... end)` — subscribe to a hook. +- `entire.command{ name=, short=, run=function(args) ... end }` — contribute a + CLI command (`entire `); `run` returns an integer exit code (nil → 0). +- `entire.log.debug|info|warn|error(msg)` — write to the Entire log. +- `entire.kv.get(key) / set(key, value) / delete(key)` — durable per-plugin + string store (JSON file in the data dir). +- `entire.json.decode(str) → value / encode(value) → string` — a JSON codec. + Objects ↔ tables, arrays ↔ 1..n sequences, numbers ↔ Lua numbers (scientific + notation such as `3e-06` is handled), and `true`/`false`/`null` ↔ + boolean/boolean/nil. `decode` raises on invalid JSON; `encode` renders a dense + 1..n table as an array and any other table (including `{}`) as an object, and + raises on values with no JSON form (functions, userdata) or cyclic tables. It + is pure and deterministic — it reaches nothing outside the Lua state — so it is + always available, **not** capability-gated. +- `entire.print(...) / entire.write(...)` — write to stdout (for commands; avoid + in hooks, where it can corrupt hook stdout). The global `print()` is routed to + the log instead. +- Read-only accessors: `entire.plugin_name`, `entire.version`, `entire.source` + (`"user"` or `"repo"`), `entire.repo_root`, `entire.data_dir`. + +### Capability-gated + +Each raises a Lua error naming the missing capability when ungranted. + +- `entire.http.get(url) / post(url, body[, content_type])` → `{status, body}` + (`http`; http/https only, 10s timeout, 5 MiB response cap). +- `entire.exec.run(cmd, arg1, ...)` → `{stdout, stderr, code}` (`exec`; 30s + timeout, runs in the repo root). +- `entire.fs.read(path) / write(path, contents)` (`fs`; confined to the repo + root and the plugin data dir — traversal outside is rejected). +- `entire.net.connect(...)` (`net`; reserved, not implemented — use `entire.http`). + +## Hooks + +Observer hooks run for side effects only; a failing or slow observer is logged +and ignored, never propagated. Each callback runs under a per-hook timeout +(2s default, `ENTIRE_PLUGIN_HOOK_TIMEOUT_MS` to raise) with panic isolation. + +| Hook | Fires | Kind | +| ------------------ | ------------------------------------------------- | -------- | +| `session_start` | agent session begins | observer | +| `turn_start` | user submits a prompt | observer | +| `turn_end` | agent finishes a turn | observer | +| `checkpoint_saved` | a session step checkpoint is written | observer | +| `post_commit` | a git commit is processed | observer | +| `pre_push` | before a git push | observer + veto | +| `subagent_end` | a subagent/task completes | observer | +| `session_end` | a session ends | observer | +| `compaction` | the agent compacts its context | observer | +| `model_update` | the agent reports the active model | observer | +| `prepare_commit_msg` | building a commit message | mutating | + +Event payloads carry operational metadata (ids, agent, model, file paths) — not +prompt text or file contents. Richer/sensitive data is reserved for +capability-gated APIs. + +### Mutating hooks + +- `prepare_commit_msg` (capability `commit_msg`): the callback may return a + trailer string appended to the commit message. Plugin trailers land **after** + the built-in `Entire-Checkpoint` trailer (never displacing it) and before the + git comment block. Multiple plugins contribute in load order. +- `pre_push` (capability `pre_push`): the callback may return `false` (with an + optional reason string) to veto the push, aborting it with a non-zero pre-push + hook exit. The veto runs **before** the built-in OPF rewrite and + checkpoint-ref push so it short-circuits that work. Plugins without the + capability still receive the observer fire; their return value is ignored. + +## Command resolution order + +`entire ` resolves in this order: + +1. **Built-in** Cobra command (always wins). +2. **Lua plugin command** (`entire.command`). +3. **`entire-` binary plugin** (kubectl-style). + +Lua commands are dispatched before the binary dispatcher, so a Lua command wins +over a same-named binary plugin; both defer to built-ins. Plugins are only loaded +when the first arg is a plugin-shaped, non-built-in name, so built-in commands +pay no discovery cost. + +## Distribution + +```bash +# From a git URL (cloned into the managed lua dir; --ref pins a tag/branch/commit) +entire plugin install https://github.com/acme/entire-notify.git --ref v1.2.0 + +# From a local directory containing a plugin.json +entire plugin install ./my-plugin + +# Update git-installed Lua plugins +entire plugin update # all +entire plugin update notify # one + +# List (Lua + binary) / remove +entire plugin list +entire plugin remove notify +``` + +Install only places files — remember to allow-list the plugin in settings before +it will run. + +## Implementation + +- `cmd/entire/cli/plugins/` — the runtime: sandbox, manifest, loader/registry, + hook bus (observer + mutating), Lua API, capability enforcement, commands. +- `cmd/entire/cli/settings/settings.go` — `PluginSettings` allow-list + capability + validation. +- `cmd/entire/cli/lua_plugin_hooks.go`, `strategy/plugin_hooks.go` — the seams + that fire hooks from lifecycle events and git hooks. +- `cmd/entire/cli/plugin_lua_command.go` — command dispatch. +- `cmd/entire/cli/plugin_lua_store.go`, `plugin_group.go` — install/update/list/remove. + +The `plugins` package deliberately does not import `cli` or `strategy` (which +import it); seams build plain `map[string]any` payloads that the package converts +to Lua tables. diff --git a/docs/security-and-privacy.md b/docs/security-and-privacy.md index f625af1460..189b711220 100644 --- a/docs/security-and-privacy.md +++ b/docs/security-and-privacy.md @@ -381,6 +381,29 @@ File an issue when the rule would benefit every Entire user (e.g., a major SaaS - **Custom PII patterns are user-authored.** Teams own the correctness of their `custom_patterns`. An invalid regex is logged and skipped, not enforced. - **Users are ultimately responsible** for reviewing what they commit and push. Redaction is a safety net, not a guarantee. +## Third-party plugins + +Entire supports two kinds of plugins, both of which run third-party code: + +- **Binary plugins** — `entire-` executables on `$PATH`. Arbitrary + programs invoked by the CLI; only install ones you trust. +- **Lua plugins** — no-build-step scripts run in an embedded sandboxed + interpreter. The trust model is opt-in at every step: a plugin is inert until + allow-listed with `plugins..enabled = true`, **repo-local + `.entire/plugins/` plugins can only be enabled from your personal, + uncommitted `.entire/settings.local.json` — never from the committed team + `.entire/settings.json`**, so cloning a hostile repo that ships both a plugin + and a team-settings enable cannot execute its code. Privileged APIs + (`http`/`exec`/`fs`/`net`) and the mutating hooks (commit-message trailer, + push veto) are capability-gated, and `entire plugin install` only places files + (it cannot run the code until you enable it). `ENTIRE_PLUGINS_DISABLED=1` is a + process-wide kill switch. + +The allow-list plus capability grants are the trust boundary — enabling a plugin +is equivalent to running its code. The sandbox limits accidental damage, not a +deliberately malicious enabled plugin. See +[Lua Plugins](architecture/plugins-lua.md) for the full model. + ## Telemetry The CLI captures anonymous usage analytics by default. Sent to PostHog with `DisableGeoIP` enabled. Captured per command: command name, selected agent, whether Entire is enabled in the repo, CLI version, OS/arch, installed git version (best-effort; omitted if git is absent or unparseable), and **names** of flags passed (never their values). The distinct ID is a hashed machine identifier (`machineid.ProtectedID`), not a user identity. @@ -401,3 +424,4 @@ For vulnerability disclosure, see [SECURITY.md](../SECURITY.md) at the repo root - [Checkpoint commit signing](architecture/checkpoint-signing.md) — best-effort GPG/SSH signing of checkpoint commits, opt-out via `sign_checkpoint_commits: false`. - External agent plugins are arbitrary executables on `$PATH` invoked by the CLI; only install plugins you trust. +- [Lua Plugins](architecture/plugins-lua.md) — embedded, sandboxed, opt-in plugins with a capability model; enabling one runs its code. diff --git a/examples/plugins/README.md b/examples/plugins/README.md new file mode 100644 index 0000000000..dc7d6b5e23 --- /dev/null +++ b/examples/plugins/README.md @@ -0,0 +1,27 @@ +# Example Lua plugins + +No-build-step plugins for the Entire CLI. See +[docs/architecture/plugins-lua.md](../../docs/architecture/plugins-lua.md) for +the full reference, and [`entire.lua`](entire.lua) for editor type hints +(EmmyLua / lua-language-server). + +| Plugin | Shows | +| ------------------------------------------ | ----------------------------------------------------------- | +| [`checkpoint-notify`](checkpoint-notify) | observer hooks, `entire.kv`, a command, optional `exec` | +| [`models-updater`](models-updater) | a command + a `session_start` hook using `http` + `fs` + `kv` | + +## Try one + +```bash +entire plugin install ./examples/plugins/checkpoint-notify +# then allow-list it (installing alone does not run it): +# .entire/settings.json → "plugins": { "checkpoint-notify": { "enabled": true, "capabilities": ["exec"] } } +entire notify-stats +``` + +## Security + +Enabling a plugin runs its code in your `entire` process. Plugins are inert +until allow-listed, repo-local plugins never auto-run, and privileged APIs are +capability-gated — but the allow-list is the trust boundary. Only enable plugins +you trust. See the trust model in the architecture doc. diff --git a/examples/plugins/checkpoint-notify/README.md b/examples/plugins/checkpoint-notify/README.md new file mode 100644 index 0000000000..03c0041c81 --- /dev/null +++ b/examples/plugins/checkpoint-notify/README.md @@ -0,0 +1,35 @@ +# checkpoint-notify (example Lua plugin) + +A minimal observer plugin demonstrating hooks, durable `entire.kv` storage, a +contributed command, and an optional capability-gated `entire.exec` call. + +## Install + +```bash +entire plugin install ./examples/plugins/checkpoint-notify +``` + +## Enable + +Installing only places files — the plugin stays inert until you allow-list it. +Add to `.entire/settings.json`: + +```json +{ + "plugins": { + "checkpoint-notify": { "enabled": true, "capabilities": ["exec"] } + } +} +``` + +The `exec` capability is optional: without it the desktop notification is +skipped (the call is guarded with `pcall`) and the plugin still logs and counts. + +## Use + +```bash +entire notify-stats # prints how many checkpoints the plugin has observed +``` + +Checkpoint/commit activity is logged to `.entire/logs/` (set `log_level` to +`DEBUG` for the full trace). diff --git a/examples/plugins/checkpoint-notify/main.lua b/examples/plugins/checkpoint-notify/main.lua new file mode 100644 index 0000000000..6d4a016bb5 --- /dev/null +++ b/examples/plugins/checkpoint-notify/main.lua @@ -0,0 +1,51 @@ +-- checkpoint-notify: a minimal observer plugin. +-- +-- On each checkpoint it bumps a persistent counter (entire.kv) and logs a line. +-- On each commit it logs the commit. If the `exec` capability is granted AND a +-- notifier is available, it sends a desktop notification (best-effort). +-- +-- Enable it in .entire/settings.json: +-- "plugins": { "checkpoint-notify": { "enabled": true, "capabilities": ["exec"] } } +-- The "exec" grant is optional; without it the desktop notification is skipped +-- and the plugin still logs and counts. + +local function bump_count() + local n = tonumber(entire.kv.get("checkpoints") or "0") + 1 + entire.kv.set("checkpoints", tostring(n)) + return n +end + +-- notify tries a couple of common notifiers; failures are swallowed so a +-- missing notifier never disrupts the checkpoint. +local function notify(title, body) + -- entire.exec is only present-and-usable with the "exec" capability. Guard + -- with pcall so an ungranted call (which raises) is a no-op. + pcall(function() + entire.exec.run("osascript", "-e", + string.format('display notification %q with title %q', body, title)) + end) +end + +entire.on("checkpoint_saved", function(ev) + local n = bump_count() + local files = 0 + if ev.modified_files then files = #ev.modified_files end + entire.log.info(string.format("checkpoint #%d saved (%d files)", n, files)) + notify("Entire checkpoint", string.format("Checkpoint #%d saved", n)) +end) + +entire.on("post_commit", function(ev) + if ev.has_checkpoint then + entire.log.info("commit " .. (ev.commit or "?") .. " linked to a checkpoint") + end +end) + +entire.command{ + name = "notify-stats", + short = "show how many checkpoints this plugin has seen", + run = function() + local n = entire.kv.get("checkpoints") or "0" + entire.print("checkpoints seen: " .. n) + return 0 + end, +} diff --git a/examples/plugins/checkpoint-notify/plugin.json b/examples/plugins/checkpoint-notify/plugin.json new file mode 100644 index 0000000000..ba22685d44 --- /dev/null +++ b/examples/plugins/checkpoint-notify/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "checkpoint-notify", + "version": "1.0.0", + "description": "Logs and (optionally) desktop-notifies on each checkpoint and commit", + "entry": "main.lua", + "hooks": ["checkpoint_saved", "post_commit"], + "commands": [{ "name": "notify-stats", "short": "show how many checkpoints this plugin has seen" }], + "capabilities": ["exec"] +} diff --git a/examples/plugins/entire.lua b/examples/plugins/entire.lua new file mode 100644 index 0000000000..3ae821d66e --- /dev/null +++ b/examples/plugins/entire.lua @@ -0,0 +1,93 @@ +--- entire.lua — type-annotation stub for the Entire plugin API. +--- +--- This file is NOT loaded at runtime. It exists so editors with an EmmyLua / +--- lua-language-server setup give you autocompletion and type hints while +--- authoring an Entire Lua plugin. Point your workspace library at the +--- directory containing this file, e.g. in .luarc.json: +--- +--- { "workspace": { "library": ["path/to/examples/plugins"] } } +--- +--- The real `entire` table is injected by the host at load time. + +---@meta + +---@class EntireEvent +---@field event string # hook name, e.g. "turn_end" +---@field agent string|nil # agent name +---@field session_id string|nil +---@field session_ref string|nil # transcript path +---@field model string|nil +---@field modified_files string[]|nil +---@field new_files string[]|nil +---@field deleted_files string[]|nil +---@field subagent_type string|nil +---@field source string|nil # prepare_commit_msg: "message" | "template" | ... +---@field commit string|nil # post_commit: commit SHA +---@field checkpoint_id string|nil +---@field has_checkpoint boolean|nil +---@field remote string|nil # pre_push +---@field push_target string|nil + +---@class EntireHttpResponse +---@field status integer +---@field body string + +---@class EntireExecResult +---@field stdout string +---@field stderr string +---@field code integer + +---@class EntireCommandSpec +---@field name string +---@field short string|nil +---@field run fun(args: string[]): integer|nil + +---@class EntireLog +---@field debug fun(msg: string) +---@field info fun(msg: string) +---@field warn fun(msg: string) +---@field error fun(msg: string) + +---@class EntireKV +---@field get fun(key: string): string|nil +---@field set fun(key: string, value: string) +---@field delete fun(key: string) + +--- JSON codec. Pure/deterministic, so always available (no capability needed). +--- decode raises on invalid JSON; encode raises on values with no JSON form +--- (functions, userdata) or cyclic tables. A dense 1..n table encodes as an +--- array; any other table (including an empty one) encodes as an object. +---@class EntireJson +---@field decode fun(str: string): any +---@field encode fun(value: any): string + +---@class EntireHttp +---@field get fun(url: string): EntireHttpResponse +---@field post fun(url: string, body: string, content_type: string|nil): EntireHttpResponse + +---@class EntireExec +---@field run fun(cmd: string, ...: string): EntireExecResult + +---@class EntireFs +---@field read fun(path: string): string +---@field write fun(path: string, contents: string) + +---@class Entire +---@field plugin_name string +---@field version string +---@field source string # "user" | "repo" +---@field repo_root string +---@field data_dir string +---@field log EntireLog +---@field kv EntireKV +---@field json EntireJson # JSON decode/encode (always available) +---@field http EntireHttp # requires "http" capability +---@field exec EntireExec # requires "exec" capability +---@field fs EntireFs # requires "fs" capability +---@field on fun(hook: string, cb: fun(event: EntireEvent): any) +---@field command fun(spec: EntireCommandSpec) +---@field print fun(...: any) # stdout (use in commands, not hooks) +---@field write fun(...: any) # stdout, no newline +entire = {} + +return entire diff --git a/examples/plugins/models-updater/README.md b/examples/plugins/models-updater/README.md new file mode 100644 index 0000000000..7895aee378 --- /dev/null +++ b/examples/plugins/models-updater/README.md @@ -0,0 +1,104 @@ +# models-updater + +An example Lua plugin that keeps a repo's cached model-pricing table +(`.entire/models.json`) fresh, and makes it obvious when it has gone stale — so +embedded pricing never rots silently. + +It exercises the whole plugin surface in one place: a **command** +(`entire.command`), an observer **hook** (`entire.on`), the **http** and **fs** +capabilities, durable **kv** state, the always-available **json** codec +(`entire.json`), and **capability-gating**. + +## What it does + +### `entire models-update` + +1. Fetches LiteLLM's public + [`model_prices_and_context_window.json`](https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json) + over `entire.http` (the `http` capability). +2. If `.entire/models.json` already exists, reads it (`entire.fs`) and **diffs** + it against the fetched data, reporting per-model input/output **rate drift**. +3. **Preserves local-only models** — any id present in your cached file but + absent upstream (your own newer or internal ids) is preserved, never erased, + and listed as manually maintained. +4. Writes the merged result back to `.entire/models.json` (`entire.fs`) and + records the refresh via `entire.kv`. +5. Prints a concise summary: models seen, rate changes, local-only preserved. + +``` +$ entire models-update +Fetching https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json ... +Rate drift detected (2 change(s)): + gpt-4o input 5e-06 -> 2.5e-06 + gpt-4o output 1.5e-05 -> 1e-05 +Keeping 1 local-only model id(s) (manually maintained, absent upstream): + - entire-internal-model +Wrote .entire/models.json — 312 models, 2 rate change(s), 1 local-only preserved (refreshed at session #7). +``` + +### `entire models-update --check` + +Fetch and report drift **without writing**, and **exit non-zero** when anything +drifted. Drop it in CI to fail the build when the committed pricing table no +longer matches upstream. + +``` +$ entire models-update --check && echo up-to-date +``` + +`--write` is accepted as the explicit form of the default (write) behavior. A +trailing positional argument overrides the source URL: + +``` +$ entire models-update https://example.com/my-prices.json +``` + +### `session_start` nudge + +On each session start the plugin emits a single gentle log line **only when the +cache looks stale** — either it has never been fetched, or it has not been +refreshed in a while — pointing you at `entire models-update`. It is quiet on +the common (fresh) path and nudges at most once per session. This is the "so it +never gets forgotten" piece. + +Staleness is measured in **sessions since the last refresh**, not days: the +plugin sandbox opens only `base`/`string`/`table`/`math`, so a plugin has no +wall clock (there is no `os` library and event payloads carry no timestamp). A +durable logical session counter in `entire.kv` is the honest staleness signal +achievable with only the `http` + `fs` capabilities. Day-based staleness (and a +real "last updated" timestamp) would need the host to expose a clock to Lua — +see the plugin's own comments. + +## Required capabilities + +| Capability | Used for | +| ---------- | --------------------------------------------------------- | +| `http` | fetching the upstream pricing JSON (`entire.http.get`) | +| `fs` | reading/writing `.entire/models.json` (`entire.fs.*`) | + +`entire.kv` (the session clock + refresh marker) and `entire.json` (decode the +fetched body, re-encode preserved local-only models) need no capability. + +## Enabling it + +Installing a plugin only places files; it stays inert until you allow-list it. + +```bash +entire plugin install ./examples/plugins/models-updater +``` + +Then grant it in settings. A **user-installed** plugin may be enabled from +either `.entire/settings.json` or `.entire/settings.local.json`: + +```json +{ + "plugins": { + "models-updater": { "enabled": true, "capabilities": ["http", "fs"] } + } +} +``` + +A **repo-local** plugin (shipped under `.entire/plugins/`) can only be enabled +from your personal, uncommitted `.entire/settings.local.json` — a committed team +`.entire/settings.json` can never auto-run it. See the trust model in +[docs/architecture/plugins-lua.md](../../../docs/architecture/plugins-lua.md). diff --git a/examples/plugins/models-updater/main.lua b/examples/plugins/models-updater/main.lua new file mode 100644 index 0000000000..705d991287 --- /dev/null +++ b/examples/plugins/models-updater/main.lua @@ -0,0 +1,295 @@ +-- models-updater: a real pricing-model updater plugin. +-- +-- Embedded model-pricing tables rot silently: rates change upstream and the +-- copy shipped in a repo drifts out of date without anyone noticing. This +-- plugin keeps `.entire/models.json` fresh and makes drift visible. +-- +-- * `entire models-update` fetch upstream, diff against the cached +-- copy, report per-model rate drift, write +-- the merged result, remember the refresh. +-- * `entire models-update --check` fetch + report drift only; exit non-zero +-- when anything drifted (CI-friendly). +-- * `entire models-update ` override the upstream source URL. +-- * a `session_start` hook gently nudges (once per session, only when +-- stale) so the refresh is never forgotten. +-- +-- It demonstrates the whole plugin surface working together: a command +-- (entire.command) + an observer hook (entire.on) + the http and fs +-- capabilities + durable kv state + capability-gating (an ungranted call fails +-- loudly). +-- +-- Enable it (installing alone does NOT run it): +-- * user-installed -> .entire/settings.json or .entire/settings.local.json +-- * repo-local -> .entire/settings.local.json only (never committed) +-- "plugins": { "models-updater": { "enabled": true, "capabilities": ["http", "fs"] } } + +local DEFAULT_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" +local DEST = ".entire/models.json" + +-- Staleness is measured in *sessions since the last refresh*, not days: the +-- plugin sandbox opens only base/string/table/math, so there is no wall clock +-- (no `os` library, and event payloads carry no timestamp). A durable logical +-- session counter is the honest, achievable staleness signal with only kv. +-- Nudge once this many sessions have elapsed without a refresh. +local STALE_AFTER_SESSIONS = 25 + +-- Durable kv keys (survive across sessions; stored under the plugin data dir). +local KV_SESSION_COUNT = "session_count" -- logical clock, bumped each session_start +local KV_LAST_UPDATED = "last_updated_session" -- session_count value at the last write + +-- The LiteLLM file carries a non-model "sample_spec" schema entry; skip it so +-- counts and drift stay about real models. +local NOT_A_MODEL = { sample_spec = true } + +-- --------------------------------------------------------------------------- +-- Model parsing. +-- +-- The runtime provides entire.json (always available — pure/deterministic, so +-- no capability is needed), so we decode the whole body in one call instead of +-- hand-rolling a scanner. The LiteLLM shape is a flat object whose values are +-- per-model objects; we keep each model's decoded value so a preserved +-- local-only id can be re-encoded (entire.json.encode) when we splice it back +-- in. Scientific-notation rates (e.g. 3e-06) parse correctly for free — no +-- custom number parser, and a rate is read by exact key so the decoy field +-- "input_cost_per_token_above_128k_tokens" is never mistaken for the real one. +-- --------------------------------------------------------------------------- + +-- rate reads a numeric rate field from a decoded model object, returning nil +-- when the field is absent or not a number (some entries omit a rate). +local function rate(model, field) + local v = model[field] + if type(v) == "number" then + return v + end + return nil +end + +-- rates_differ compares two rates with a small relative tolerance, so a genuine +-- rate change registers as drift while floating-point noise does not. (Values +-- written in different notations now decode to the same number, so 0.000003 and +-- 3e-06 compare equal regardless.) +local function rates_differ(a, b) + if a == nil or b == nil then + return a ~= b + end + local diff = math.abs(a - b) + local scale = math.max(math.abs(a), math.abs(b)) + return diff > scale * 1e-9 +end + +-- parse_models decodes a models.json body (a JSON object of per-model objects) +-- into a name -> { value, input, output } map plus the sorted list of model +-- names (sans the non-model sample_spec entry). `value` is the decoded model +-- object, kept so a local-only model can be re-encoded when spliced back into a +-- refreshed file. +local function parse_models(body) + local root = entire.json.decode(body) + local by_name, order = {}, {} + if type(root) ~= "table" then + return by_name, order -- not the object-of-objects shape we expect + end + for name, value in pairs(root) do + if type(value) == "table" and not NOT_A_MODEL[name] then + by_name[name] = { + value = value, + input = rate(value, "input_cost_per_token"), + output = rate(value, "output_cost_per_token"), + } + order[#order + 1] = name + end + end + table.sort(order) -- pairs() order is unspecified; sort for a stable count + return by_name, order +end + +-- --------------------------------------------------------------------------- +-- Diff / merge helpers. +-- --------------------------------------------------------------------------- + +local function fmt_rate(v) + if v == nil then + return "(none)" + end + return string.format("%.10g", v) +end + +-- rate_drift lists the per-model input/output rate changes between the cached +-- and freshly fetched tables, for models present in both. +local function rate_drift(old, new) + local lines = {} + for name, nv in pairs(new) do + local ov = old[name] + if ov then + if rates_differ(ov.input, nv.input) then + lines[#lines + 1] = string.format("%s input %s -> %s", name, fmt_rate(ov.input), fmt_rate(nv.input)) + end + if rates_differ(ov.output, nv.output) then + lines[#lines + 1] = string.format("%s output %s -> %s", name, fmt_rate(ov.output), fmt_rate(nv.output)) + end + end + end + table.sort(lines) + return lines +end + +-- local_only_models lists model ids present in the cached copy but absent +-- upstream: our own newer/internal ids that must be preserved, never erased. +local function local_only_models(old, new) + local names = {} + for name in pairs(old) do + if not new[name] then + names[#names + 1] = name + end + end + table.sort(names) + return names +end + +-- splice_local_only reinserts the preserved (local-only) models just before the +-- closing brace of the freshly fetched upstream body, so the upstream bytes are +-- otherwise untouched and the local ids survive the refresh. Each preserved +-- model is re-encoded from its decoded value with entire.json.encode. +local function splice_local_only(upstream, old, keep_names) + local close = upstream:find("}%s*$") + if not close then + return upstream -- not a shape we recognize; leave it alone + end + local head = upstream:sub(1, close - 1):gsub("%s+$", "") + local tail = upstream:sub(close) + local blocks = {} + for _, name in ipairs(keep_names) do + blocks[#blocks + 1] = entire.json.encode(name) .. ": " .. entire.json.encode(old[name].value) + end + local sep = head:match("{%s*$") and "\n " or ",\n " -- no comma if upstream was empty + return head .. sep .. table.concat(blocks, ",\n ") .. "\n" .. tail +end + +-- read_file returns the file contents or nil when it does not exist. +-- entire.fs.read raises on a missing file, so guard it with pcall. +local function read_file(path) + local ok, data = pcall(function() + return entire.fs.read(path) -- requires the "fs" capability + end) + if ok then + return data + end + return nil +end + +local function report_diff(drifts, kept) + if #drifts == 0 then + entire.print("No rate drift versus the cached copy.") + else + entire.print(string.format("Rate drift detected (%d change(s)):", #drifts)) + for _, line in ipairs(drifts) do + entire.print(" " .. line) + end + end + if #kept > 0 then + entire.print(string.format("Keeping %d local-only model id(s) (manually maintained, absent upstream):", #kept)) + for _, name in ipairs(kept) do + entire.print(" - " .. name) + end + end +end + +-- --------------------------------------------------------------------------- +-- Command: entire models-update [--check] [--write] [url] +-- --------------------------------------------------------------------------- + +local function run(args) + local url = DEFAULT_URL + local check = false + for _, a in ipairs(args) do + if a == "--check" then + check = true + elseif a == "--write" then + check = false -- explicit default; accepted for symmetry with --check + elseif a:sub(1, 2) == "--" then + entire.print("models-update: unknown flag " .. a) + return 2 + else + url = a + end + end + + entire.print("Fetching " .. url .. " ...") + local resp = entire.http.get(url) -- requires the "http" capability + if resp.status ~= 200 then + entire.print(string.format("error: server returned HTTP %d", resp.status)) + return 1 + end + + local new_by_name, new_order = parse_models(resp.body) + local total = #new_order + + local existing = read_file(DEST) + local drifts, kept, old_by_name = {}, {}, nil + if existing then + old_by_name = parse_models(existing) + drifts = rate_drift(old_by_name, new_by_name) + kept = local_only_models(old_by_name, new_by_name) + report_diff(drifts, kept) + else + entire.print("No cached " .. DEST .. " yet — this will be the first copy.") + end + + -- --check: report only, never write; non-zero exit signals drift to CI. + if check then + entire.print(string.format("Checked %d upstream models; %d rate change(s). (--check: nothing written)", total, #drifts)) + if #drifts > 0 then + return 1 + end + return 0 + end + + local merged = resp.body + if old_by_name and #kept > 0 then + merged = splice_local_only(resp.body, old_by_name, kept) + end + entire.fs.write(DEST, merged) -- requires the "fs" capability; confined to the repo + + -- Remember when we refreshed, on the logical session clock (see the note by + -- STALE_AFTER_SESSIONS for why this is not a wall-clock timestamp). + local marker = entire.kv.get(KV_SESSION_COUNT) or "0" + entire.kv.set(KV_LAST_UPDATED, marker) + + entire.log.info(string.format("models.json refreshed from %s (%d models, %d drifted, %d preserved)", url, total, #drifts, #kept)) + entire.print(string.format( + "Wrote %s — %d models, %d rate change(s), %d local-only preserved (refreshed at session #%s).", + DEST, total, #drifts, #kept, marker + )) + return 0 +end + +entire.command{ + name = "models-update", + short = "refresh .entire/models.json and report pricing drift", + run = run, +} + +-- --------------------------------------------------------------------------- +-- Hook: session_start — the gentle "don't let it rot" nudge. +-- +-- Fires once per session. We bump the logical session clock silently and only +-- emit a single log line when the cache looks stale, so the hook stays quiet on +-- the common (fresh) path. We log rather than print: hook stdout can be consumed +-- by the host agent, and printing there could corrupt it. +-- --------------------------------------------------------------------------- + +entire.on("session_start", function() + local count = (tonumber(entire.kv.get(KV_SESSION_COUNT) or "0") or 0) + 1 + entire.kv.set(KV_SESSION_COUNT, tostring(count)) + + local updated = entire.kv.get(KV_LAST_UPDATED) + if updated == nil then + entire.log.warn("Entire pricing data has never been fetched here — run `entire models-update`") + return + end + + local age = count - (tonumber(updated) or 0) + if age >= STALE_AFTER_SESSIONS then + entire.log.warn(string.format( + "Entire pricing data was last refreshed %d sessions ago — run `entire models-update`", age)) + end +end) diff --git a/examples/plugins/models-updater/plugin.json b/examples/plugins/models-updater/plugin.json new file mode 100644 index 0000000000..6880943e2d --- /dev/null +++ b/examples/plugins/models-updater/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "models-updater", + "version": "1.0.0", + "description": "Refreshes .entire/models.json from upstream, reports per-model pricing drift, preserves local-only ids, and nudges when the cache goes stale (command + session_start hook, http + fs + kv)", + "entry": "main.lua", + "hooks": ["session_start"], + "commands": [{ "name": "models-update", "short": "refresh .entire/models.json and report pricing drift" }], + "capabilities": ["http", "fs"] +} diff --git a/go.mod b/go.mod index 56a3a78b2f..0a0102d887 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 + github.com/yuin/gopher-lua v1.1.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.54.0 golang.org/x/mod v0.38.0 diff --git a/go.sum b/go.sum index 0d22ca0c3a..0ef07c4a16 100644 --- a/go.sum +++ b/go.sum @@ -299,6 +299,8 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=