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
138 changes: 138 additions & 0 deletions crates/agent-gateway/internal/kbrain/permission_rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package kbrain

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"

gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2"
)

// permissionRulesInput is everything the settings page may send; other fields
// (session_id, unknown keys) are dropped before K-brain sees the request.
type permissionRulesInput struct {
Workspace string `json:"workspace"`
Rule string `json:"rule"`
Effect string `json:"effect"`
Scope string `json:"scope"`
ID string `json:"id"`
}

// PermissionRules serves Settings → Permissions for a direct K-brain target: list,
// add and delete remembered rules through /v1/permissions/rules. Session rules stay
// with their session and are not reachable here.
func (r *Relay) PermissionRules(ctx context.Context, req *gatewayv2.PermissionRulesRequest) (*gatewayv2.PermissionRulesResponse, error) {
if req == nil {
return nil, errors.New("invalid permission rules request")
}
var in permissionRulesInput
if raw := strings.TrimSpace(req.GetInputJson()); raw != "" {
if err := json.Unmarshal([]byte(raw), &in); err != nil {
return nil, errors.New("invalid permission rules request: input must be a JSON object")
}
}
workspace := strings.TrimSpace(in.Workspace)
in.Effect, in.Scope = strings.TrimSpace(in.Effect), strings.TrimSpace(in.Scope)
const path = "/v1/permissions/rules"
var (
method string
target string
body any
)
switch strings.TrimSpace(req.GetAction()) {
case "list":
method, target = http.MethodGet, path+permissionRulesQuery("workspace", workspace)
case "add":
rule := strings.TrimSpace(in.Rule)
if rule == "" {
return nil, errors.New("invalid permission rules request: rule is required")
}
if in.Effect != "allow" && in.Effect != "deny" {
return nil, errors.New("invalid permission rules request: effect must be allow or deny")
}
if err := checkPermissionRuleScope(in.Scope, workspace); err != nil {
return nil, err
}
create := map[string]string{"rule": rule, "effect": in.Effect, "scope": in.Scope}
if in.Scope == "project" {
create["workspace"] = workspace
}
method, target, body = http.MethodPost, path, create
case "delete":
id := strings.TrimSpace(in.ID)
if id == "" {
return nil, errors.New("invalid permission rules request: id is required")
}
if err := checkPermissionRuleScope(in.Scope, workspace); err != nil {
return nil, err
}
query := permissionRulesQuery("scope", in.Scope)
if in.Scope == "project" {
query += "&workspace=" + url.QueryEscape(workspace)
}
method, target = http.MethodDelete, path+"/"+url.PathEscape(id)+query
default:
return nil, errors.New("invalid permission rules request: action must be list, add or delete")
}
raw, err := r.permissionRulesRequest(ctx, method, target, body)
if err != nil {
return nil, err
}
return &gatewayv2.PermissionRulesResponse{ResultJson: string(raw)}, nil
}

func checkPermissionRuleScope(scope, workspace string) error {
switch scope {
case "user":
return nil
case "project":
if workspace == "" {
return errors.New("invalid permission rules request: project scope needs a workspace")
}
return nil
}
return errors.New("invalid permission rules request: scope must be project or user")
}

func permissionRulesQuery(key, value string) string {
if value == "" {
return ""
}
return "?" + key + "=" + url.QueryEscape(value)
}

// permissionRulesRequest returns K-brain's JSON answer; a refusal comes back as
// K-brain's own explanation, which the settings page shows as is.
func (r *Relay) permissionRulesRequest(ctx context.Context, method, path string, input any) (json.RawMessage, error) {
resp, err := r.client.request(ctx, method, path, input, nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
const maxAnswer = 4 << 20
data, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswer+1))
if err != nil {
return nil, err
}
if len(data) > maxAnswer {
return nil, errors.New("kbrain permission rules response is too large")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
var failure struct {
Error string `json:"error"`
}
if json.Unmarshal(data, &failure) == nil && strings.TrimSpace(failure.Error) != "" {
return nil, errors.New(strings.TrimSpace(failure.Error))
}
return nil, fmt.Errorf("kbrain permission rules request: %s", resp.Status)
}
if !json.Valid(data) {
return nil, errors.New("kbrain permission rules response is not JSON")
}
return data, nil
}
116 changes: 116 additions & 0 deletions crates/agent-gateway/internal/kbrain/permission_rules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package kbrain

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2"
)

type recordedRequest struct {
method string
uri string
body map[string]any
}

func permissionRulesRelay(t *testing.T, status int, answer string) (*Relay, *[]recordedRequest) {
t.Helper()
var seen []recordedRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
raw, _ := io.ReadAll(req.Body)
record := recordedRequest{method: req.Method, uri: req.URL.RequestURI()}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &record.body)
}
seen = append(seen, record)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, answer)
}))
t.Cleanup(server.Close)
client, err := New(server.URL, "secret-token", server.Client())
if err != nil {
t.Fatal(err)
}
relay, err := NewRelay(client, "target", ModelRef{}, "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { relay.Close() })
return relay, &seen
}

// The WebUI reaches K-brain's rules API through the relay with only the fields the
// settings page needs; the answer is K-brain's JSON as is.
func TestPermissionRulesRelayMapsActionsToTheRulesAPI(t *testing.T) {
relay, seen := permissionRulesRelay(t, http.StatusOK, `{"project_root":"D:\\w","rules":[{"id":"r_1","rule":"Bash(ls:*)","effect":"allow","scope":"project"}]}`)
response, err := relay.PermissionRules(context.Background(), &gatewayv2.PermissionRulesRequest{Action: "list", InputJson: `{"workspace":"D:\\w","session_id":"s1"}`})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(response.GetResultJson(), `"r_1"`) {
t.Fatalf("result = %s", response.GetResultJson())
}
if got := (*seen)[0]; got.method != http.MethodGet || got.uri != "/v1/permissions/rules?workspace=D%3A%5Cw" {
t.Fatalf("list request = %+v", got)
}

if _, err := relay.PermissionRules(context.Background(), &gatewayv2.PermissionRulesRequest{Action: "add", InputJson: `{"rule":"Bash(rm:*)","effect":"deny","scope":"project","workspace":"/w","session_id":"s1","extra":1}`}); err != nil {
t.Fatal(err)
}
add := (*seen)[1]
if add.method != http.MethodPost || add.uri != "/v1/permissions/rules" {
t.Fatalf("add request = %+v", add)
}
want := map[string]any{"rule": "Bash(rm:*)", "effect": "deny", "scope": "project", "workspace": "/w"}
if len(add.body) != len(want) {
t.Fatalf("add body = %v, want only %v", add.body, want)
}
for key, value := range want {
if add.body[key] != value {
t.Fatalf("add body = %v, want %v", add.body, want)
}
}

if _, err := relay.PermissionRules(context.Background(), &gatewayv2.PermissionRulesRequest{Action: "delete", InputJson: `{"id":"r 1/x","scope":"user"}`}); err != nil {
t.Fatal(err)
}
if got := (*seen)[2]; got.method != http.MethodDelete || got.uri != "/v1/permissions/rules/r%201%2Fx?scope=user" {
t.Fatalf("delete request = %+v", got)
}
}

// Session rules and malformed requests never reach K-brain.
func TestPermissionRulesRelayRejectsBadInputLocally(t *testing.T) {
relay, seen := permissionRulesRelay(t, http.StatusOK, `{}`)
for _, req := range []*gatewayv2.PermissionRulesRequest{
{Action: "add", InputJson: `{"rule":"Bash(rm:*)","effect":"deny","scope":"session"}`},
{Action: "add", InputJson: `{"rule":"Bash(rm:*)","effect":"ask","scope":"user"}`},
{Action: "add", InputJson: `{"rule":" ","effect":"deny","scope":"user"}`},
{Action: "add", InputJson: `{"rule":"Bash(rm:*)","effect":"deny","scope":"project"}`},
{Action: "delete", InputJson: `{"scope":"user"}`},
{Action: "delete", InputJson: `{"id":"r_1","scope":"session"}`},
{Action: "rename", InputJson: `{}`},
} {
if _, err := relay.PermissionRules(context.Background(), req); err == nil {
t.Fatalf("%s %s: expected rejection", req.GetAction(), req.GetInputJson())
}
}
if len(*seen) != 0 {
t.Fatalf("rejected requests reached K-brain: %+v", *seen)
}
}

// K-brain explains a refused rule in {"error"}; the WebUI shows that text, not the JSON.
func TestPermissionRulesRelayKeepsKBrainErrorText(t *testing.T) {
relay, _ := permissionRulesRelay(t, http.StatusBadRequest, `{"error":"permission rule \"Bash(\": missing closing parenthesis","version":"kbrain.agent.v1"}`)
_, err := relay.PermissionRules(context.Background(), &gatewayv2.PermissionRulesRequest{Action: "add", InputJson: `{"rule":"Bash(","effect":"deny","scope":"user"}`})
if err == nil || err.Error() != `permission rule "Bash(": missing closing parenthesis` {
t.Fatalf("error = %v", err)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package kbrain

import (
"context"
"net/http"
"strings"
"testing"

gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2"
)

// LA-3b review, round 2: effect/scope are trimmed like the desktop bridge does, so the
// same input gets the same answer on both arms.
func TestReviewLA3bR2PermissionRulesRelayTrimsEffectAndScope(t *testing.T) {
relay, seen := permissionRulesRelay(t, http.StatusCreated, `{"id":"r_1"}`)
if _, err := relay.PermissionRules(context.Background(), &gatewayv2.PermissionRulesRequest{
Action: "add",
InputJson: `{"rule":" Bash(rm:*) ","effect":" deny ","scope":" project ","workspace":" /w "}`,
}); err != nil {
t.Fatal(err)
}
if _, err := relay.PermissionRules(context.Background(), &gatewayv2.PermissionRulesRequest{
Action: "delete",
InputJson: `{"id":" r_1 ","scope":" user "}`,
}); err != nil {
t.Fatal(err)
}
got := *seen
if len(got) != 2 {
t.Fatalf("seen %v", got)
}
want := map[string]any{"rule": "Bash(rm:*)", "effect": "deny", "scope": "project", "workspace": "/w"}
for key, value := range want {
if got[0].body[key] != value {
t.Fatalf("add body %v, want %v", got[0].body, want)
}
}
if len(got[0].body) != len(want) {
t.Fatalf("add body %v has extra fields", got[0].body)
}
if got[1].method != http.MethodDelete || got[1].uri != "/v1/permissions/rules/r_1?scope=user" {
t.Fatalf("delete = %s %s", got[1].method, got[1].uri)
}
}

// Exactly 4 MiB of JSON still passes; one byte more is reported as too large, whether
// K-brain answered 200 or an error.
func TestReviewLA3bR2PermissionRulesRelayAnswerLimit(t *testing.T) {
const limit = 4 << 20
fill := func(size int) string {
prefix, suffix := `{"rules":[],"pad":"`, `"}`
return prefix + strings.Repeat("x", size-len(prefix)-len(suffix)) + suffix
}
list := &gatewayv2.PermissionRulesRequest{Action: "list"}

relay, _ := permissionRulesRelay(t, http.StatusOK, fill(limit))
response, err := relay.PermissionRules(context.Background(), list)
if err != nil || len(response.GetResultJson()) != limit {
t.Fatalf("4 MiB answer: len=%d err=%v", len(response.GetResultJson()), err)
}
for _, status := range []int{http.StatusOK, http.StatusBadRequest} {
relay, _ := permissionRulesRelay(t, status, fill(limit+1))
if _, err := relay.PermissionRules(context.Background(), list); err == nil || !strings.Contains(err.Error(), "too large") {
t.Fatalf("status %d, 4 MiB + 1: err=%v", status, err)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package kbrain

import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2"
)

// LA-3b review: workspace and id reach K-brain exactly as typed, read the way K-brain's
// permissionRules handler reads them (r.URL.Query(), TrimPrefix + PathUnescape on the
// already-decoded r.URL.Path). Covers space (QueryEscape writes "+"), literal "+", "%",
// "#", "?", "&" and CJK.
func TestReviewLA3bPermissionRulesRelayEncodingMatchesKBrainDecoding(t *testing.T) {
type got struct{ workspace, scope, id string }
var seen []got
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
entry := got{workspace: strings.TrimSpace(query.Get("workspace")), scope: query.Get("scope")}
if rest := strings.TrimPrefix(r.URL.Path, "/v1/permissions/rules"); strings.HasPrefix(rest, "/") {
id, err := url.PathUnescape(strings.TrimPrefix(rest, "/"))
if err != nil {
id = "<invalid: " + err.Error() + ">"
}
entry.id = id
}
seen = append(seen, entry)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"rules":[]}`))
}))
defer server.Close()
client, err := New(server.URL, "t", server.Client())
if err != nil {
t.Fatal(err)
}
relay, err := NewRelay(client, "target", ModelRef{}, "")
if err != nil {
t.Fatal(err)
}
defer relay.Close()

workspace := `D:\项目\a b+c%20#?&x=1`
for _, req := range []*gatewayv2.PermissionRulesRequest{
{Action: "list", InputJson: `{"workspace":` + quote(workspace) + `}`},
{Action: "delete", InputJson: `{"id":"r_Ab-9","scope":"project","workspace":` + quote(workspace) + `}`},
} {
if _, err := relay.PermissionRules(context.Background(), req); err != nil {
t.Fatalf("%s: %v", req.GetAction(), err)
}
}
want := []got{{workspace: workspace}, {workspace: workspace, scope: "project", id: "r_Ab-9"}}
if len(seen) != len(want) {
t.Fatalf("seen %v", seen)
}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("request %d: K-brain read %+v, want %+v", i, seen[i], want[i])
}
}
}

func quote(value string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(value) + `"`
}
Loading
Loading