Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ cortex/
│ ├── session-budget/ # Redis-backed budget tracking
│ └── context-guru/ # Opt-in context-guru plugin
│
├── tests/ # Python tests (keycloak_sync)
├── examples/script-plugins/ # Example script plugins; copied by hand, not installed
├── tests/ # Python tests
├── go.work # Workspace linking 8 of the 11 Go modules
├── .github/
│ ├── workflows/ # CI/CD (ci.yaml, build.yaml, release-binaries.yaml,
Expand Down
9 changes: 4 additions & 5 deletions cmd/cortex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -610,13 +610,12 @@ func main() {
ctx, cancelCtx := context.WithCancel(context.Background())
defer cancelCtx()
// The script plugin's folders are watched as well as the config, so adding or
// editing a script reloads as a config edit does. The folders come from the
// startup config: a slot added later is watched from the next restart, though
// the reload that adds it loads its scripts.
slots := scriptFolders(cfg)
// editing a script reloads as a config edit does. The reloader asks each config
// it puts into effect for its folders, so a slot a config edit adds is watched
// from that reload on.
rld := reloader.New(*configPath, inboundH, outboundH, buildPipelines, cfg,
reloader.WithOnCommit(applyPricing),
reloader.WithExtraWatch(slots, func() string { return scriptFingerprint(slots) }))
reloader.WithExtraWatch(scriptFolders, scriptFingerprint))
if err := rld.Start(ctx); err != nil {
log.Fatalf("reloader: %v", err)
}
Expand Down
36 changes: 28 additions & 8 deletions cmd/cortex/scripts.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,41 @@ import (
// imported so that this file builds with or without the plugin's tag.
const scriptPlugin = "script"

// addScriptSlot appends a `script` entry to the end of the outbound chain of a local
// install that names none, so a user who drops a file into ~/.cortex/plugins has it
// running without editing YAML. A chain that names `script` anywhere — including
// with on_error: off — is the user's own arrangement and is left alone. A binary
// built without the plugin adds nothing, since the entry could only fail to load.
// addScriptSlot puts the default slot, ~/.cortex/plugins itself, into the outbound
// chain of a local install, so a user who drops a file there has it running without
// editing YAML.
//
// A chain that names no script entry gets it appended. A chain whose script entries
// all name a dir — a slot another tool added, such as the moca-sandbox example's —
// gets it just before the first built one, so adding a slot does not switch the
// user's own scripts off. Before, not after: a slot that rewrites responses must
// come after every response reader, and the default slot's scripts may read them.
// A chain that already has an entry for the default slot, under any on_error,
// including off, is the user's own arrangement and is left alone. A binary built
// without the plugin adds nothing, since the entry could only fail to load.
func addScriptSlot(c *config.Config, local bool) {
if !local || !slices.Contains(plugins.RegisteredPlugins(), scriptPlugin) {
return
}
for _, e := range c.Pipeline.Outbound.Plugins {
if e.Name == scriptPlugin {
chain := c.Pipeline.Outbound.Plugins
first := -1
for i, e := range chain {
if e.Name != scriptPlugin {
continue
}
if entryDir(e) == "" {
return
}
if first < 0 && e.OnError.Resolved() != pipeline.ErrorPolicyOff {
first = i
}
}
slot := config.PluginEntry{Name: scriptPlugin}
if first < 0 {
c.Pipeline.Outbound.Plugins = append(chain, slot)
return
}
c.Pipeline.Outbound.Plugins = append(c.Pipeline.Outbound.Plugins, config.PluginEntry{Name: scriptPlugin})
c.Pipeline.Outbound.Plugins = slices.Insert(chain, first, slot)
}

// checkScriptSlots refuses a chain with two built `script` entries on one folder:
Expand Down
92 changes: 92 additions & 0 deletions cmd/cortex/scripts_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/rossoctl/cortex/core/config"
Expand Down Expand Up @@ -53,3 +54,94 @@ func TestLocalChain_BuildsWithEachKindOfScript(t *testing.T) {
})
}
}

// A user's response-reading script in the default slot, next to a slot that
// rewrites responses (the moca-sandbox example's shape), still builds: the default
// slot goes first, because a response reader must precede every response writer.
func TestLocalChain_BuildsWithAResponseRewritingSlotAdded(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("SHELL", "")
root := filepath.Join(home, ".cortex", "plugins")
scripts := map[string]string{
filepath.Join(root, "watch"): `{"cortex":1,"hooks":["response"]}`,
filepath.Join(root, "moca-sandbox", "guard"): `{"cortex":1,"hooks":["request","response"],"enforce":true,"rewrite_response":true}`,
}
for path, handshake := range scripts {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("#!/bin/sh\necho '"+handshake+"'\ncat >/dev/null\n"), 0o700); err != nil {
t.Fatal(err)
}
}
p, err := writeBuiltinConfig(filepath.Join(home, ".cortex"), filepath.Join(home, ".cortex", "ca"))
if err != nil {
t.Fatal(err)
}
cfg, err := config.Load(p)
if err != nil {
t.Fatal(err)
}
config.ApplyPreset(cfg)
cfg.Pipeline.Outbound.Plugins = append(cfg.Pipeline.Outbound.Plugins,
config.PluginEntry{Name: "script", Config: []byte(`{"dir":"moca-sandbox","fail_closed":true}`)})
addScriptSlot(cfg, true)
pipe, err := plugins.BuildWithDeps(cfg.Pipeline.Outbound.Plugins,
plugins.Deps{Listener: forwardproxy.Support(false), LocalInstall: true})
if err != nil {
t.Fatalf("the local chain did not build: %v", err)
}
pipe.Stop(t.Context())
}

// The mirror of the case above: a response rewriter in the default slot cannot go
// before a named slot that only reads responses, so the inserted chain is refused.
// Listing the default slot after the named one builds.
func TestLocalChain_ADefaultSlotRewriterBeforeANamedReader(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("SHELL", "")
root := filepath.Join(home, ".cortex", "plugins")
scripts := map[string]string{
filepath.Join(root, "rewrite"): `{"cortex":1,"hooks":["response"],"rewrite_response":true}`,
filepath.Join(root, "audit", "watch"): `{"cortex":1,"hooks":["response"]}`,
}
for path, handshake := range scripts {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("#!/bin/sh\necho '"+handshake+"'\ncat >/dev/null\n"), 0o700); err != nil {
t.Fatal(err)
}
}
chain := func(extra ...config.PluginEntry) *config.Config {
p, err := writeBuiltinConfig(filepath.Join(home, ".cortex"), filepath.Join(home, ".cortex", "ca"))
if err != nil {
t.Fatal(err)
}
cfg, err := config.Load(p)
if err != nil {
t.Fatal(err)
}
config.ApplyPreset(cfg)
cfg.Pipeline.Outbound.Plugins = append(cfg.Pipeline.Outbound.Plugins, extra...)
addScriptSlot(cfg, true)
return cfg
}
build := func(cfg *config.Config) error {
pipe, err := plugins.BuildWithDeps(cfg.Pipeline.Outbound.Plugins,
plugins.Deps{Listener: forwardproxy.Support(false), LocalInstall: true})
if err == nil {
pipe.Stop(t.Context())
}
return err
}
audit := config.PluginEntry{Name: "script", Config: []byte(`{"dir":"audit"}`)}
if err := build(chain(audit)); err == nil || !strings.Contains(err.Error(), "reads body after mutator") {
t.Errorf("the default slot inserted before a named response reader: err = %v, want the reader-after-mutator refusal", err)
}
if err := build(chain(audit, config.PluginEntry{Name: "script"})); err != nil {
t.Errorf("the default slot listed after the named reader did not build: %v", err)
}
}
58 changes: 39 additions & 19 deletions cmd/cortex/scripts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@
package main

import (
"slices"
"testing"

"github.com/rossoctl/cortex/core/config"
"github.com/rossoctl/cortex/core/pipeline"
)

// outboundNames lists the outbound chain as name, or name:dir for a script slot
// that names one.
func outboundNames(c *config.Config) []string {
var out []string
for _, e := range c.Pipeline.Outbound.Plugins {
out = append(out, e.Name)
if d := entryDir(e); d != "" {
out = append(out, e.Name+":"+d)
} else {
out = append(out, e.Name)
}
}
return out
}
Expand All @@ -24,25 +31,38 @@ func TestAddScriptSlot(t *testing.T) {
return c
}
parser := config.PluginEntry{Name: "inference-parser"}
prune := config.PluginEntry{Name: "tool-prune"}
moca := config.PluginEntry{Name: "script", Config: []byte(`{"dir":"moca-sandbox","fail_closed":true}`)}
early := config.PluginEntry{Name: "script", Config: []byte(`{"dir":"early"}`)}
mocaOff := config.PluginEntry{Name: "script", Config: moca.Config, OnError: pipeline.ErrorPolicyOff}
earlyOff := config.PluginEntry{Name: "script", Config: early.Config, OnError: pipeline.ErrorPolicyOff}
def := config.PluginEntry{Name: "script"}
defOff := config.PluginEntry{Name: "script", OnError: pipeline.ErrorPolicyOff}
emptyDir := config.PluginEntry{Name: "script", Config: []byte(`{"dir":""}`)}

c := chain(parser)
addScriptSlot(c, true)
if got := outboundNames(c); len(got) != 2 || got[1] != "script" {
t.Errorf("local install: %v, want script appended last", got)
}

c = chain(parser)
addScriptSlot(c, false)
if got := outboundNames(c); len(got) != 1 {
t.Errorf("not a local install: %v, want nothing added", got)
}

// A chain that names script — even switched off — is the user's arrangement.
off := config.PluginEntry{Name: "script", OnError: pipeline.ErrorPolicyOff}
c = chain(off, parser)
addScriptSlot(c, true)
if got := outboundNames(c); len(got) != 2 || got[0] != "script" {
t.Errorf("user's own script entry: %v, want it left alone", got)
for _, tc := range []struct {
name string
local bool
in []config.PluginEntry
want []string
}{
{"no script entry: appended last", true, []config.PluginEntry{parser, prune}, []string{"inference-parser", "tool-prune", "script"}},
{"not a local install: nothing added", false, []config.PluginEntry{parser}, []string{"inference-parser"}},
{"a named slot: the default goes just before it", true, []config.PluginEntry{parser, prune, moca}, []string{"inference-parser", "tool-prune", "script", "script:moca-sandbox"}},
{"two named slots: before the first", true, []config.PluginEntry{parser, early, prune, moca}, []string{"inference-parser", "script", "script:early", "tool-prune", "script:moca-sandbox"}},
{"the user's default after a named slot: left alone", true, []config.PluginEntry{parser, moca, def}, []string{"inference-parser", "script:moca-sandbox", "script"}},
{"the user's default switched off: left alone", true, []config.PluginEntry{defOff, parser, moca}, []string{"script", "inference-parser", "script:moca-sandbox"}},
{"an explicit empty dir is the default slot", true, []config.PluginEntry{parser, moca, emptyDir}, []string{"inference-parser", "script:moca-sandbox", "script"}},
{"a named slot switched off: not a place to insert", true, []config.PluginEntry{mocaOff, parser}, []string{"script:moca-sandbox", "inference-parser", "script"}},
{"a switched-off slot first: before the first built one", true, []config.PluginEntry{earlyOff, parser, prune, moca}, []string{"script:early", "inference-parser", "tool-prune", "script", "script:moca-sandbox"}},
} {
t.Run(tc.name, func(t *testing.T) {
c := chain(slices.Clone(tc.in)...)
addScriptSlot(c, tc.local)
if got := outboundNames(c); !slices.Equal(got, tc.want) {
t.Errorf("chain = %v, want %v", got, tc.want)
}
})
}
}

Expand Down
55 changes: 46 additions & 9 deletions core/plugins/script/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
// A script declares, in its handshake, which hooks it implements and whether it
// enforces. It starts in shadow mode — its denies and edits are recorded and not
// applied — so trying a script cannot break a request. A script that errors (a
// crash, a timeout, a malformed reply) lets the request through unless its slot is
// fail_closed.
// crash, a timeout, a malformed reply) or does not start lets the request through
// unless its slot is fail_closed.
//
// Scripts run only on a local install (plugins.Deps.LocalInstall). Anywhere else a
// `script` entry loads nothing, so a config that names one cannot run commands
Expand Down Expand Up @@ -52,7 +52,7 @@ var pool = worker.NewPool()
type config struct {
Dir string `json:"dir" description:"Folder of scripts, relative to ~/.cortex/plugins. Empty: that folder itself." default:""`
Timeout string `json:"timeout" description:"How long one script may take to answer one hook." default:"2s"`
FailClosed bool `json:"fail_closed" description:"Deny the request when a script errors, instead of letting it through." default:"false"`
FailClosed bool `json:"fail_closed" description:"Deny the request when a script errors or did not start, instead of letting it through." default:"false"`
}

// Script is the plugin.
Expand All @@ -66,7 +66,10 @@ type Script struct {
// runs nothing.
holder int
loaded []*loaded
m metrics
// missing names the scripts in the folder that did not start, in file order.
// A fail_closed slot refuses every request while it is not empty.
missing []string
m metrics
// stateKey is where this slot keeps a streamed response's frames on the
// request's context. Per folder: two slots in one chain see the same frames,
// and a reload's rebuild of a slot carries on with the copy the old build kept.
Expand Down Expand Up @@ -134,7 +137,9 @@ func (s *Script) SetLocalInstall(local bool) { s.local = local }

// Configure loads the slot's scripts and starts their workers. A script that will
// not start is skipped with a WARN, never failing the build: one broken file must
// not stop the proxy from starting or a config edit from applying.
// not stop the proxy from starting or a config edit from applying. A fail_closed
// slot remembers it and refuses requests until a build in which it starts; see
// notStarted.
func (s *Script) Configure(raw json.RawMessage) error {
s.configured = true
if len(raw) > 0 {
Expand Down Expand Up @@ -201,11 +206,13 @@ func (s *Script) Configure(raw json.RawMessage) error {
}()
}
wg.Wait()
for _, l := range results {
if l != nil {
s.loaded = append(s.loaded, l)
slog.Info("script plugin: loaded", "script", l.name, "hooks", l.hs.Hooks, "enforce", l.hs.Enforce)
for i, l := range results {
if l == nil {
s.missing = append(s.missing, files[i].name)
continue
}
s.loaded = append(s.loaded, l)
slog.Info("script plugin: loaded", "script", l.name, "hooks", l.hs.Hooks, "enforce", l.hs.Enforce)
}
return nil
}
Expand Down Expand Up @@ -250,6 +257,11 @@ func SlotDir(dir string) (string, error) {
// request as the scripts before it left it, except that a shadow script's edits
// are not applied and so are not seen.
func (s *Script) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action {
if s.cfg.FailClosed && len(s.missing) > 0 {
if a := s.notStarted(pctx); a.Type != pipeline.Continue {
return a
}
}
var msg map[string]any
for _, l := range s.loaded {
if !l.hs.Has("request") {
Expand Down Expand Up @@ -620,6 +632,30 @@ func (s *Script) failed(pctx *pipeline.Context, l *loaded, err error, logs []str
return pipeline.Action{}, false
}

// notStarted refuses a request in a fail_closed slot while one of its scripts did
// not start. The slot cannot tell which requests that script would have acted on,
// so it refuses them all: fail_closed means a request the script never saw does
// not go through. Under on_error: observe the refusal is recorded as shadow, as
// failed records one.
func (s *Script) notStarted(pctx *pipeline.Context) pipeline.Action {
name := s.missing[0]
inv := pipeline.Invocation{
Plugin: name,
Action: pipeline.ActionObserve,
Reason: "script_not_started",
Details: map[string]string{"error": "did not start; see the Cortex log", "not_started": strings.Join(s.missing, ",")},
Shadow: pctx.Observing(),
}
if inv.Shadow {
pctx.Record(inv)
return pipeline.Action{}
}
inv.Action = pipeline.ActionDeny
pctx.Record(inv)
v := &pipeline.Violation{Code: "plugin.script-failed", Reason: "script " + name + " did not start", Status: 502, PluginName: name}
return pipeline.Action{Type: pipeline.Reject, Violation: v}
}

// denyStatus is a script's deny status when it is an HTTP error, and otherwise 0,
// which lets the violation's code choose one. A 200 would present the refusal to
// the agent as an answer, and a status outside 100-999 panics net/http.
Expand Down Expand Up @@ -656,6 +692,7 @@ func withLogs(d map[string]string, logs []string) map[string]string {
func (s *Script) Metrics() []pipeline.Metric {
return []pipeline.Metric{
{Name: "scripts loaded", Value: float64(len(s.loaded))},
{Name: "scripts not started", Value: float64(len(s.missing)), Note: "a fail_closed slot refuses every request while this is not 0"},
{Name: "calls", Value: float64(s.m.calls.Load())},
{Name: "errors", Value: float64(s.m.errors.Load())},
{Name: "denies", Value: float64(s.m.denies.Load())},
Expand Down
Loading
Loading