mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edf3f0fa7 | ||
|
|
62e65f1235 | ||
|
|
e2fd4908f7 |
@@ -0,0 +1,103 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// alwaysAllowDenylist holds the programs an argv allow rule may never name:
|
||||
// with flags unconstrained, an allow rule for one of them runs arbitrary code
|
||||
// or does unbounded damage. It is code-owned on purpose (no setting, no env
|
||||
// var). Names are compared after argvProgramName: basename, lower case, no
|
||||
// version suffix (python3.11 -> python, mkfs.ext4 -> mkfs).
|
||||
var alwaysAllowDenylist = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, group := range []string{
|
||||
// interpreters and shells
|
||||
"sh bash zsh dash ksh fish csh tcsh ash mksh rbash pwsh powershell python pypy ipython perl ruby irb " +
|
||||
"node deno bun php lua luajit rscript tclsh wish expect awk gawk mawk nawk sed jshell osascript",
|
||||
// launchers: run their arguments (or a flag's value) as a command
|
||||
"env command builtin exec eval source . nohup timeout nice ionice stdbuf setsid chroot unshare nsenter " +
|
||||
"systemd-run su sudo doas pkexec runuser setpriv sg watch busybox toybox xargs parallel make gmake " +
|
||||
"mapfile trap script find ssh scp sftp rsync socat ncat nc netcat flock strace ltrace gdb " +
|
||||
"taskset chrt unbuffer rlwrap firejail bwrap proot proxychains torsocks tar zip vim vi nvim emacs less more man",
|
||||
// package managers and build tools: run package scripts and hooks
|
||||
"npm npx pnpm yarn bunx corepack pip pipx uv uvx poetry conda composer gem bundle bundler rake cargo go " +
|
||||
"mvn gradle dotnet apt apt-get dpkg dnf yum rpm pacman apk brew snap flatpak",
|
||||
// orchestration: run images, playbooks and plans
|
||||
"docker docker-compose podman nerdctl kubectl helm ansible ansible-playbook terraform pulumi vagrant",
|
||||
// destructive
|
||||
"rm dd mkfs mkswap wipefs fdisk sfdisk parted shred truncate chmod chown kill pkill killall systemctl service " +
|
||||
"iptables ip6tables nft ufw mount umount swapoff shutdown reboot poweroff halt init telinit crontab " +
|
||||
"insmod rmmod modprobe",
|
||||
} {
|
||||
for _, name := range strings.Fields(group) {
|
||||
m[name] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// argvProgramName normalises a program token for the denylist: the basename,
|
||||
// in lower case, without a trailing version (python3.11, php8.2, perl5, pip3)
|
||||
// and with every mkfs.* variant reduced to mkfs.
|
||||
func argvProgramName(tok string) string {
|
||||
name := strings.ToLower(path.Base(tok))
|
||||
if strings.HasPrefix(name, "mkfs.") {
|
||||
return "mkfs"
|
||||
}
|
||||
// Strip a trailing run of digits and dots, but only after a letter, so a
|
||||
// name made only of digits (or ".") is kept as is.
|
||||
end := len(name)
|
||||
for end > 0 && (name[end-1] == '.' || (name[end-1] >= '0' && name[end-1] <= '9')) {
|
||||
end--
|
||||
}
|
||||
if end > 0 && end < len(name) {
|
||||
return name[:end]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ArgvAllowDenied reports whether an argv allow rule whose first token is
|
||||
// program is refused by the denylist.
|
||||
func ArgvAllowDenied(program string) bool {
|
||||
return alwaysAllowDenylist[argvProgramName(program)]
|
||||
}
|
||||
|
||||
// CheckArgvAllow returns the error shown when an allow rule (manual or from
|
||||
// "always allow") names a denylisted program, nil otherwise.
|
||||
func CheckArgvAllow(tokens []string) error {
|
||||
if len(tokens) > 0 && ArgvAllowDenied(tokens[0]) {
|
||||
return fmt.Errorf("program %q cannot be always allowed: it can run arbitrary code or cause unbounded damage whatever its arguments", tokens[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AlwaysAllowPrefix derives the argv pattern an "always allow" of a pending
|
||||
// call would create: Bash only, one simple command, a non-empty prefix whose
|
||||
// program is not denylisted.
|
||||
func AlwaysAllowPrefix(toolName string, input []byte) ([]string, error) {
|
||||
if toolName != "Bash" {
|
||||
return nil, errors.New("always allow applies to Bash commands only")
|
||||
}
|
||||
var in struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return nil, fmt.Errorf("the tool input is not a Bash call: %w", err)
|
||||
}
|
||||
prefix, err := ArgvPrefix(in.Command)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no prefix can be derived from this command: %w", err)
|
||||
}
|
||||
if len(prefix) == 0 {
|
||||
return nil, errors.New("no prefix can be derived from this command")
|
||||
}
|
||||
if err := CheckArgvAllow(prefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestArgvAllowDenylist(t *testing.T) {
|
||||
for _, prog := range []string{
|
||||
"python", "python3", "python3.11", "/usr/bin/python3.11", "php8.2", "ruby3.2", "perl5", "pip3",
|
||||
"env", "/usr/bin/env", "bash", "/bin/sh", "find", "rm", "mkfs", "mkfs.ext4", "/sbin/mkfs.xfs",
|
||||
"docker", "kubectl", "npm", "sudo", "xargs", "Rscript", ".",
|
||||
} {
|
||||
if !ArgvAllowDenied(prog) {
|
||||
t.Errorf("%q is not denied", prog)
|
||||
}
|
||||
}
|
||||
for _, prog := range []string{"nmap", "git", "/usr/bin/git", "curl", "ls", "gh", "x", "7z", "pythonic"} {
|
||||
if ArgvAllowDenied(prog) {
|
||||
t.Errorf("%q is denied, want allowable", prog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlwaysAllowPrefix(t *testing.T) {
|
||||
bash := func(cmd string) []byte {
|
||||
b, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
return b
|
||||
}
|
||||
if got, err := AlwaysAllowPrefix("Bash", bash("git push --force origin main")); err != nil || !slices.Equal(got, []string{"git", "push"}) {
|
||||
t.Fatalf("git push: %q %v", got, err)
|
||||
}
|
||||
if got, err := AlwaysAllowPrefix("Bash", bash("nmap -p 80 10.0.0.1")); err != nil || !slices.Equal(got, []string{"nmap"}) {
|
||||
t.Fatalf("nmap: %q %v", got, err)
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
tool string
|
||||
input []byte
|
||||
}{
|
||||
"not Bash": {"shell_open", bash("git push")},
|
||||
"compound": {"Bash", bash("git push; id")},
|
||||
"substitution": {"Bash", bash("git push $(id)")},
|
||||
"redirection": {"Bash", bash("git push > out")},
|
||||
"empty": {"Bash", bash("")},
|
||||
"no command": {"Bash", []byte(`{}`)},
|
||||
"not json": {"Bash", []byte(`not json`)},
|
||||
"python": {"Bash", bash("python3.11 -c 'import os'")},
|
||||
"path to python": {"Bash", bash("/usr/bin/python3 x.py")},
|
||||
"find": {"Bash", bash("find . -exec sh -c id ;")},
|
||||
"rm": {"Bash", bash("rm -rf /")},
|
||||
"docker": {"Bash", bash("docker run --privileged x")},
|
||||
} {
|
||||
if got, err := AlwaysAllowPrefix(c.tool, c.input); err == nil {
|
||||
t.Errorf("%s: prefix %q, want a refusal", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The denylist neutralises argv allow rules at matching time (a rule created
|
||||
// before the denylist, or written straight into the database), and only them:
|
||||
// an argv deny or ask rule for the same program still matches.
|
||||
func TestArgvDenylistAtMatching(t *testing.T) {
|
||||
ask := db.InterceptRule{ID: 99, Name: "ask every Bash call", Enabled: true, Priority: -2000, MatchTarget: "tool_name",
|
||||
MatchType: "string", Pattern: "Bash", Action: "ask"}
|
||||
argv := func(id int64, pattern, action string) db.InterceptRule {
|
||||
return db.InterceptRule{ID: id, Name: "argv " + action + " " + pattern, Enabled: true, Priority: -1000,
|
||||
MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: action}
|
||||
}
|
||||
ic := New(nil)
|
||||
ic.SetRulesForTest([]db.InterceptRule{
|
||||
argv(1, `["python"]`, "allow"), argv(2, `["python3.11"]`, "allow"), argv(3, `["find"]`, "allow"),
|
||||
argv(4, `["/usr/bin/env"]`, "allow"), argv(5, `["nmap"]`, "allow"), ask,
|
||||
})
|
||||
for _, cmd := range []string{"python x.py", "python3.11 -c 'import os'", "find . -name x", "/usr/bin/env"} {
|
||||
input, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
dec, ok := ic.Match("Bash", input)
|
||||
if !ok || dec.Action != "ask" || dec.RuleID != 99 {
|
||||
t.Errorf("%q: %+v %v, want the fallback ask rule", cmd, dec, ok)
|
||||
}
|
||||
}
|
||||
if dec, ok := ic.Match("Bash", []byte(`{"command":"nmap -p 80 x"}`)); !ok || dec.Action != "allow" || dec.RuleID != 5 {
|
||||
t.Errorf("nmap: %+v %v, want the argv allow", dec, ok)
|
||||
}
|
||||
|
||||
ic.SetRulesForTest([]db.InterceptRule{argv(1, `["python"]`, "allow"), argv(6, `["python"]`, "deny"), argv(7, `["find"]`, "ask")})
|
||||
if dec, ok := ic.Match("Bash", []byte(`{"command":"python x.py"}`)); !ok || dec.Action != "deny" || dec.RuleID != 6 {
|
||||
t.Errorf("python: %+v %v, want the argv deny", dec, ok)
|
||||
}
|
||||
if dec, ok := ic.Match("Bash", []byte(`{"command":"find . -delete"}`)); !ok || dec.Action != "ask" || dec.RuleID != 7 {
|
||||
t.Errorf("find: %+v %v, want the argv ask", dec, ok)
|
||||
}
|
||||
}
|
||||
@@ -753,6 +753,11 @@ func (i *Interceptor) SetRulesForTest(rules []db.InterceptRule) {
|
||||
func ruleMatches(r compiledRule, toolName string, input []byte) bool {
|
||||
if r.MatchType == "argv" {
|
||||
// Bash only, on the parsed command; match_target is always tool_input.
|
||||
// The denylist neutralises allow rules only: an argv deny or ask rule
|
||||
// for a denylisted program still matches.
|
||||
if r.Action == "allow" && CheckArgvAllow(r.argv) != nil {
|
||||
return false
|
||||
}
|
||||
return r.MatchTarget == "tool_input" && argvRuleMatches(r.argv, toolName, input)
|
||||
}
|
||||
var subject string
|
||||
|
||||
+81
-2
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -413,6 +414,67 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// alwaysAllowPriority places "always allow" rules below every default and
|
||||
// manual rule, so any deny or ask rule that matches the same call wins.
|
||||
const alwaysAllowPriority = -1000
|
||||
|
||||
// interceptAlwaysAllow creates an argv allow rule from a pending Bash request.
|
||||
// The request body is never read: the command comes from the stored row, so a
|
||||
// caller cannot choose what gets allowed. The request itself stays pending.
|
||||
func (s *Server) interceptAlwaysAllow(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "bad pending id")
|
||||
return
|
||||
}
|
||||
p, err := pg.GetInterceptPending(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if p == nil {
|
||||
writeErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
if p.Status != "pending" {
|
||||
writeErr(w, 409, "the request is no longer pending")
|
||||
return
|
||||
}
|
||||
prefix, err := intercept.AlwaysAllowPrefix(p.ToolName, p.ToolInput)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
pattern, _ := json.Marshal(prefix)
|
||||
rules, err := pg.ListInterceptRules()
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.MatchType != "argv" || rule.Action != "allow" || !rule.Enabled || rule.MatchTarget != "tool_input" {
|
||||
continue
|
||||
}
|
||||
if tokens, err := intercept.ParseArgvPattern(rule.Pattern); err == nil && slices.Equal(tokens, prefix) {
|
||||
writeJSON(w, 200, map[string]any{"rule": rule, "created": false})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Timeout fields take the rule form's defaults.
|
||||
rule, err := pg.CreateInterceptRule("always-allow: "+strings.Join(prefix, " "), "tool_input", "argv", string(pattern),
|
||||
"allow", "", alwaysAllowPriority, true, true, 60, "deny")
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
s.m.interceptor.Invalidate()
|
||||
writeJSON(w, 200, map[string]any{"rule": rule, "created": true})
|
||||
}
|
||||
|
||||
// --- target pins (hosts a human approved for one task or one conversation) ---
|
||||
|
||||
// taskPinScope resolves {id} to the pin scope of an existing task.
|
||||
@@ -667,9 +729,15 @@ func validateInterceptRuleReq(req interceptRuleReq) error {
|
||||
if req.MatchTarget != "tool_input" {
|
||||
return fmt.Errorf("an argv rule matches tool_input (the Bash command)")
|
||||
}
|
||||
if _, err := intercept.ParseArgvPattern(req.Pattern); err != nil {
|
||||
tokens, err := intercept.ParseArgvPattern(req.Pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid argv pattern: %w", err)
|
||||
}
|
||||
if req.Action == "allow" {
|
||||
if err := intercept.CheckArgvAllow(tokens); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("match_type must be string, regex or argv")
|
||||
}
|
||||
@@ -708,7 +776,18 @@ func (s *Server) interceptDetail(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, detail)
|
||||
// The "always allow" button label: the prefix the endpoint would allow,
|
||||
// derived here so the UI never parses the command.
|
||||
out := struct {
|
||||
*db.InterceptDetail
|
||||
AlwaysAllowPrefix string `json:"always_allow_prefix,omitempty"`
|
||||
}{InterceptDetail: detail}
|
||||
if detail.Status == "pending" {
|
||||
if prefix, err := intercept.AlwaysAllowPrefix(detail.ToolName, detail.ToolInput); err == nil {
|
||||
out.AlwaysAllowPrefix = strings.Join(prefix, " ")
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// The navigation endpoint returns only the original call and its paired result.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// PG test: needs a PostgreSQL database (ARTEX_PG_DSN); skipped without one.
|
||||
// POST /api/intercept/pending/{id}/always-allow derives the rule from the
|
||||
// stored row, never from the body, and leaves the request pending.
|
||||
func TestInterceptAlwaysAllowPG(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
}
|
||||
d, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
m := &Manager{pg: d, interceptor: intercept.New(d)}
|
||||
s := &Server{m: m, jwtKey: []byte("always-allow-test-key")}
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const scope = "always-allow-http"
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, scope)
|
||||
_, _ = d.Exec(`DELETE FROM intercept_rules WHERE name LIKE 'always-allow: %'`)
|
||||
})
|
||||
pending := func(tool, cmd string) int64 {
|
||||
t.Helper()
|
||||
input, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
id, err := d.CreateInterceptPending(0, 0, scope, "test", tool, input, "ask")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
post := func(id int64, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/api/intercept/pending/%d/always-allow", id), strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
ruleCount := func() int {
|
||||
t.Helper()
|
||||
rules, err := d.ListInterceptRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(rules)
|
||||
}
|
||||
type result struct {
|
||||
Rule db.InterceptRule `json:"rule"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
id := pending("Bash", "gh pr list --json title")
|
||||
before := ruleCount()
|
||||
// The body carries dummy tokens and another command: all of it is ignored.
|
||||
dummy := `{"pending_id":1,"command":"rm -rf /","pattern":"[\"rm\"]","token":"dummy-token","access_token":"dummy"}`
|
||||
rec := post(id, dummy)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("always-allow: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var got result
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := got.Rule
|
||||
if !got.Created || r.Pattern != `["gh","pr","list"]` || r.MatchType != "argv" || r.MatchTarget != "tool_input" ||
|
||||
r.Action != "allow" || !r.Enabled || r.Priority != -1000 || r.Name != "always-allow: gh pr list" ||
|
||||
!r.TimeoutEnabled || r.TimeoutSeconds != 60 || r.TimeoutAction != "deny" {
|
||||
t.Fatalf("rule %+v created=%v", r, got.Created)
|
||||
}
|
||||
if n := ruleCount(); n != before+1 {
|
||||
t.Fatalf("rule count %d, want %d", n, before+1)
|
||||
}
|
||||
if p, err := d.GetInterceptPending(id); err != nil || p == nil || p.Status != "pending" {
|
||||
t.Fatalf("pending row after always-allow: %+v %v", p, err)
|
||||
}
|
||||
// The cache was invalidated: the new rule matches at once.
|
||||
if dec, ok := m.interceptor.Match("Bash", []byte(`{"command":"gh pr list --limit 5"}`)); !ok || dec.RuleID != r.ID {
|
||||
t.Fatalf("match after creation: %+v %v", dec, ok)
|
||||
}
|
||||
|
||||
// An identical rule is reused, not duplicated.
|
||||
rec = post(pending("Bash", "gh pr list"), "")
|
||||
var again result
|
||||
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &again) != nil || again.Created || again.Rule.ID != r.ID {
|
||||
t.Fatalf("second always-allow: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if n := ruleCount(); n != before+1 {
|
||||
t.Fatalf("rule count after reuse %d, want %d", n, before+1)
|
||||
}
|
||||
|
||||
// Refusals: 400 and no rule.
|
||||
for name, c := range map[string][2]string{
|
||||
"not Bash": {"shell_open", "gh pr list"},
|
||||
"compound": {"Bash", "gh pr list; id"},
|
||||
"denylisted": {"Bash", "python3.11 -c 'import os'"},
|
||||
"path to env": {"Bash", "/usr/bin/env gh pr list"},
|
||||
} {
|
||||
rid := pending(c[0], c[1])
|
||||
if rec := post(rid, dummy); rec.Code != 400 {
|
||||
t.Errorf("%s: %d %s, want 400", name, rec.Code, rec.Body)
|
||||
}
|
||||
if p, _ := d.GetInterceptPending(rid); p == nil || p.Status != "pending" {
|
||||
t.Errorf("%s: pending row changed: %+v", name, p)
|
||||
}
|
||||
}
|
||||
if n := ruleCount(); n != before+1 {
|
||||
t.Fatalf("rule count after refusals %d, want %d", n, before+1)
|
||||
}
|
||||
|
||||
// A decided request is refused.
|
||||
decided := pending("Bash", "gh pr view 1")
|
||||
if err := d.DecideInterceptPending(decided, "denied"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := post(decided, ""); rec.Code != 409 {
|
||||
t.Fatalf("decided request: %d %s, want 409", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// The detail carries the prefix for the button label.
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/intercept/history/%d", id), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
drec := httptest.NewRecorder()
|
||||
h.ServeHTTP(drec, req)
|
||||
var detail struct {
|
||||
Status string `json:"status"`
|
||||
AlwaysAllowPrefix string `json:"always_allow_prefix"`
|
||||
}
|
||||
if drec.Code != 200 || json.Unmarshal(drec.Body.Bytes(), &detail) != nil || detail.AlwaysAllowPrefix != "gh pr list" || detail.Status != "pending" {
|
||||
t.Fatalf("detail: %d %s", drec.Code, drec.Body)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
func TestValidateArgvRuleReq(t *testing.T) {
|
||||
base := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: `["git","push"]`, Action: "allow"}
|
||||
@@ -23,3 +27,28 @@ func TestValidateArgvRuleReq(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manual rule form refuses an argv allow rule for a denylisted program,
|
||||
// with the message of the "always allow" endpoint; deny and ask stay creatable.
|
||||
func TestValidateArgvAllowDenylist(t *testing.T) {
|
||||
for _, pattern := range []string{`["python"]`, `["python3.11"]`, `["/usr/bin/env"]`, `["find"]`, `["docker","run"]`} {
|
||||
req := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: "allow"}
|
||||
tokens, _ := intercept.ParseArgvPattern(pattern)
|
||||
want := intercept.CheckArgvAllow(tokens)
|
||||
if err := validateInterceptRuleReq(req); err == nil || want == nil || err.Error() != want.Error() {
|
||||
t.Errorf("allow %s: %v, want %v", pattern, err, want)
|
||||
}
|
||||
for _, action := range []string{"deny", "ask"} {
|
||||
req.Action = action
|
||||
if err := validateInterceptRuleReq(req); err != nil {
|
||||
t.Errorf("%s %s refused: %v", action, pattern, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pattern := range []string{`["nmap"]`, `["git","push"]`} {
|
||||
req := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: "allow"}
|
||||
if err := validateInterceptRuleReq(req); err != nil {
|
||||
t.Errorf("allow %s refused: %v", pattern, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,6 +958,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/intercept/pending", s.interceptListPending)
|
||||
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
|
||||
mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide)
|
||||
mux.HandleFunc("POST /api/intercept/pending/{id}/always-allow", s.interceptAlwaysAllow) // creates an argv allow rule; the request stays pending
|
||||
mux.HandleFunc("GET /api/intercept/history", s.interceptHistory)
|
||||
mux.HandleFunc("GET /api/intercept/history/{id}", s.interceptDetail)
|
||||
mux.HandleFunc("GET /api/intercept/history/{id}/execution", s.interceptExecution)
|
||||
|
||||
@@ -297,6 +297,38 @@ function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; bus
|
||||
);
|
||||
}
|
||||
|
||||
// AlwaysAllowAction creates an argv allow rule from a pending Bash request.
|
||||
// The prefix comes from the server; the request itself stays pending.
|
||||
function AlwaysAllowAction({ id, prefix }: { id: number; prefix?: string }) {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.interceptAlwaysAllow(id);
|
||||
toast.success(
|
||||
res.created
|
||||
? "Rule created, the request is still pending approval"
|
||||
: "Rule already exists, the request is still pending approval",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button size="sm" variant="outline" className="self-start" disabled={busy} onClick={() => void create()}>
|
||||
{prefix ? `Toujours autoriser "${prefix} *"` : "Toujours autoriser"}
|
||||
</Button>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
The prefix does not constrain flags, and it does not cover what the program does next (git hooks, npm
|
||||
scripts, docker images).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalDetail({
|
||||
row,
|
||||
busy,
|
||||
@@ -418,6 +450,9 @@ export function ApprovalDetail({
|
||||
) : null}
|
||||
</dl>
|
||||
{!readOnly ? <DecisionActions row={current} busy={busy} decide={decide} /> : null}
|
||||
{!readOnly && current.status === "pending" && row.tool_name === "Bash" ? (
|
||||
<AlwaysAllowAction id={row.id} prefix={detail?.always_allow_prefix} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
|
||||
@@ -1232,6 +1232,9 @@ export const api = {
|
||||
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
|
||||
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
// No body: the server derives the rule from the stored request, which stays pending.
|
||||
interceptAlwaysAllow: (id: number) =>
|
||||
post<{ rule: InterceptRule; created: boolean }>(`/intercept/pending/${id}/always-allow`),
|
||||
interceptExecution: (id: number, conversationId?: number) =>
|
||||
get<import("@/lib/types").InterceptExecution>(
|
||||
`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`,
|
||||
|
||||
@@ -1560,6 +1560,7 @@ export interface InterceptAudit {
|
||||
}
|
||||
export interface InterceptDetail extends InterceptApprovalRow {
|
||||
audit: InterceptAudit | null;
|
||||
always_allow_prefix?: string; // pending Bash request: the argv prefix "always allow" would allow
|
||||
}
|
||||
|
||||
export type TrafficEvidenceRole = "baseline" | "proof" | "verification" | "supporting";
|
||||
|
||||
Reference in New Issue
Block a user