-
Notifications
You must be signed in to change notification settings - Fork 3
feat(ai): Let the assistant edit files, run actions, and control deployments #185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -303,6 +303,145 @@ func (s *Server) aiToolRegistry() map[string]aiTool { | |||||||||||||||||
| return truncateToolOutput(content), nil | ||||||||||||||||||
| }, | ||||||||||||||||||
| }, | ||||||||||||||||||
| "write_deployment_file": { | ||||||||||||||||||
| Spec: ai.Tool{ | ||||||||||||||||||
| Name: "write_deployment_file", | ||||||||||||||||||
| Description: "Create or overwrite a text file inside a deployment's directory, for example a config or compose file. Requires write access to the deployment; the path cannot escape the deployment directory.", | ||||||||||||||||||
| Parameters: objSchema(map[string]interface{}{ | ||||||||||||||||||
| "deployment": strProp("Deployment name. Omit to use the session's deployment."), | ||||||||||||||||||
| "path": strProp("Path to the file relative to the deployment directory."), | ||||||||||||||||||
| "content": strProp("The full new contents of the file."), | ||||||||||||||||||
| }, "path", "content"), | ||||||||||||||||||
| }, | ||||||||||||||||||
| Run: func(s *Server, c *gin.Context, bound string, args map[string]interface{}) (string, error) { | ||||||||||||||||||
| name, err := s.toolAllowedDeploymentWrite(c, bound, args) | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| path := argString(args, "path") | ||||||||||||||||||
| if path == "" { | ||||||||||||||||||
| return "", fmt.Errorf("path is required") | ||||||||||||||||||
| } | ||||||||||||||||||
| content, ok := args["content"].(string) | ||||||||||||||||||
| if !ok { | ||||||||||||||||||
| return "", fmt.Errorf("content is required") | ||||||||||||||||||
| } | ||||||||||||||||||
| if blocked, reason, perr := s.protectedDeploymentActionBlocked(name, protectedActionUploadFile); perr != nil { | ||||||||||||||||||
| return "", fmt.Errorf("could not check protected mode: %w", perr) | ||||||||||||||||||
| } else if blocked { | ||||||||||||||||||
| return "", fmt.Errorf("%s", reason) | ||||||||||||||||||
| } | ||||||||||||||||||
| if err := s.filesManager.WriteFile(name, path, strings.NewReader(content)); err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| return fmt.Sprintf("Wrote %d bytes to %s in %s.", len(content), path, name), nil | ||||||||||||||||||
| }, | ||||||||||||||||||
| }, | ||||||||||||||||||
| "run_quick_action": { | ||||||||||||||||||
| Spec: ai.Tool{ | ||||||||||||||||||
| Name: "run_quick_action", | ||||||||||||||||||
| Description: "Run one of a deployment's configured quick actions by its id. Requires write access to the deployment.", | ||||||||||||||||||
| Parameters: objSchema(map[string]interface{}{ | ||||||||||||||||||
| "deployment": strProp("Deployment name. Omit to use the session's deployment."), | ||||||||||||||||||
| "action_id": strProp("The id of the quick action to run."), | ||||||||||||||||||
| }, "action_id"), | ||||||||||||||||||
| }, | ||||||||||||||||||
| Run: func(s *Server, c *gin.Context, bound string, args map[string]interface{}) (string, error) { | ||||||||||||||||||
| name, err := s.toolAllowedDeploymentWrite(c, bound, args) | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| actionID := argString(args, "action_id") | ||||||||||||||||||
| if actionID == "" { | ||||||||||||||||||
| return "", fmt.Errorf("action_id is required") | ||||||||||||||||||
| } | ||||||||||||||||||
| if blocked, reason, perr := s.protectedDeploymentActionBlocked(name, protectedActionQuickAction); perr != nil { | ||||||||||||||||||
| return "", fmt.Errorf("could not check protected mode: %w", perr) | ||||||||||||||||||
| } else if blocked { | ||||||||||||||||||
| return "", fmt.Errorf("%s", reason) | ||||||||||||||||||
| } | ||||||||||||||||||
| output, err := s.manager.ExecuteQuickAction(name, actionID) | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| redactor := ai.NewRedactor(s.deploymentSecretValues(name)) | ||||||||||||||||||
| redacted, _ := redactor.Redact(output) | ||||||||||||||||||
| return truncateToolOutput(redacted), nil | ||||||||||||||||||
| }, | ||||||||||||||||||
| }, | ||||||||||||||||||
| "control_deployment": { | ||||||||||||||||||
| Spec: ai.Tool{ | ||||||||||||||||||
| Name: "control_deployment", | ||||||||||||||||||
| Description: "Start, stop, or restart a whole deployment. Requires write access to the deployment.", | ||||||||||||||||||
| Parameters: objSchema(map[string]interface{}{ | ||||||||||||||||||
| "deployment": strProp("Deployment name. Omit to use the session's deployment."), | ||||||||||||||||||
| "action": map[string]interface{}{ | ||||||||||||||||||
| "type": "string", | ||||||||||||||||||
| "enum": []string{"start", "stop", "restart"}, | ||||||||||||||||||
| "description": "What to do: start, stop, or restart.", | ||||||||||||||||||
| }, | ||||||||||||||||||
| }, "action"), | ||||||||||||||||||
| }, | ||||||||||||||||||
| Run: func(s *Server, c *gin.Context, bound string, args map[string]interface{}) (string, error) { | ||||||||||||||||||
| name, err := s.toolAllowedDeploymentWrite(c, bound, args) | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| action := argString(args, "action") | ||||||||||||||||||
| var output string | ||||||||||||||||||
| switch action { | ||||||||||||||||||
| case "start": | ||||||||||||||||||
| output, err = s.manager.StartDeployment(name) | ||||||||||||||||||
| case "stop": | ||||||||||||||||||
| output, err = s.manager.StopDeployment(name) | ||||||||||||||||||
| case "restart": | ||||||||||||||||||
| output, err = s.manager.RestartDeployment(name) | ||||||||||||||||||
| default: | ||||||||||||||||||
| return "", fmt.Errorf("action must be start, stop, or restart") | ||||||||||||||||||
| } | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| return truncateToolOutput(fmt.Sprintf("Ran %s on deployment %s.\n%s", action, name, output)), nil | ||||||||||||||||||
| }, | ||||||||||||||||||
| }, | ||||||||||||||||||
| "get_security_events": { | ||||||||||||||||||
| Spec: ai.Tool{ | ||||||||||||||||||
| Name: "get_security_events", | ||||||||||||||||||
| Description: "List recent security events for a deployment (blocked requests, scanners, auth failures) so they can be summarized. Read-only.", | ||||||||||||||||||
| Parameters: objSchema(map[string]interface{}{ | ||||||||||||||||||
| "deployment": strProp("Deployment name. Omit to use the session's deployment."), | ||||||||||||||||||
| "limit": map[string]interface{}{"type": "integer", "description": "Maximum events to return (default 50)."}, | ||||||||||||||||||
| }), | ||||||||||||||||||
| }, | ||||||||||||||||||
| Run: func(s *Server, c *gin.Context, bound string, args map[string]interface{}) (string, error) { | ||||||||||||||||||
| name, err := s.toolAllowedDeployment(c, bound, args) | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| if s.securityManager == nil { | ||||||||||||||||||
| return "The security module is not enabled.", nil | ||||||||||||||||||
| } | ||||||||||||||||||
| limit := 50 | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While a default limit is provided, there is no upper bound enforced on the
Suggested change
|
||||||||||||||||||
| if v, ok := args["limit"].(float64); ok && v > 0 { | ||||||||||||||||||
| limit = int(v) | ||||||||||||||||||
| } | ||||||||||||||||||
| events, _, err := s.securityManager.GetEventsByDeployment(name, limit) | ||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| if len(events) == 0 { | ||||||||||||||||||
| return fmt.Sprintf("No security events for %s.", name), nil | ||||||||||||||||||
| } | ||||||||||||||||||
| var b strings.Builder | ||||||||||||||||||
| fmt.Fprintf(&b, "%d recent security events for %s:\n", len(events), name) | ||||||||||||||||||
| for _, e := range events { | ||||||||||||||||||
| fmt.Fprintf(&b, "- %s [%s] %s %s %d from %s: %s\n", | ||||||||||||||||||
| e.CreatedAt.Format(time.RFC3339), e.Severity, e.RequestMethod, e.RequestPath, e.StatusCode, e.SourceIP, e.Message) | ||||||||||||||||||
| } | ||||||||||||||||||
| return truncateToolOutput(b.String()), nil | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security event logs can often contain sensitive data such as session tokens, API keys, or personal information within request paths or log messages. To prevent accidental exposure of these secrets to the AI model or session logs, the output should be processed through the redactor using the deployment's secret values, consistent with how the
Suggested change
|
||||||||||||||||||
| }, | ||||||||||||||||||
| }, | ||||||||||||||||||
| "exec_in_service": { | ||||||||||||||||||
| Spec: ai.Tool{ | ||||||||||||||||||
| Name: "exec_in_service", | ||||||||||||||||||
|
|
@@ -330,7 +469,9 @@ func (s *Server) aiToolRegistry() map[string]aiTool { | |||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return "", err | ||||||||||||||||||
| } | ||||||||||||||||||
| if blocked, reason, perr := s.protectedDeploymentActionBlocked(name, protectedActionExec); perr == nil && blocked { | ||||||||||||||||||
| if blocked, reason, perr := s.protectedDeploymentActionBlocked(name, protectedActionExec); perr != nil { | ||||||||||||||||||
| return "", fmt.Errorf("could not check protected mode: %w", perr) | ||||||||||||||||||
| } else if blocked { | ||||||||||||||||||
| return "", fmt.Errorf("%s", reason) | ||||||||||||||||||
| } | ||||||||||||||||||
| ctx, cancel := context.WithTimeout(c.Request.Context(), 20*time.Second) | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/flatrun/agent/internal/auth" | ||
| "github.com/flatrun/agent/internal/contextkeys" | ||
| "github.com/flatrun/agent/pkg/models" | ||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| func toolCtx(actor *auth.ActorContext) *gin.Context { | ||
| c, _ := gin.CreateTestContext(httptest.NewRecorder()) | ||
| c.Request = httptest.NewRequest(http.MethodPost, "/", nil) | ||
| if actor != nil { | ||
| c.Set(contextkeys.Actor, actor) | ||
| } | ||
| return c | ||
| } | ||
|
|
||
| func TestWriteDeploymentFileTool(t *testing.T) { | ||
| s, tmpDir, _ := setupPlanTestServer(t) | ||
| createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) | ||
|
|
||
| tool := s.aiToolRegistry()["write_deployment_file"] | ||
| out, err := tool.Run(s, toolCtx(nil), "myapp", map[string]interface{}{ | ||
| "path": "config/app.conf", | ||
| "content": "key = value\n", | ||
| }) | ||
| if err != nil { | ||
| t.Fatalf("write failed: %v", err) | ||
| } | ||
| if !strings.Contains(out, "app.conf") { | ||
| t.Errorf("unexpected output %q", out) | ||
| } | ||
| data, err := os.ReadFile(filepath.Join(tmpDir, "myapp", "config", "app.conf")) | ||
| if err != nil || string(data) != "key = value\n" { | ||
| t.Errorf("file not written correctly: %q err=%v", string(data), err) | ||
| } | ||
| } | ||
|
|
||
| func TestWriteDeploymentFileRefusesTraversal(t *testing.T) { | ||
| s, tmpDir, _ := setupPlanTestServer(t) | ||
| createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) | ||
|
|
||
| tool := s.aiToolRegistry()["write_deployment_file"] | ||
| if _, err := tool.Run(s, toolCtx(nil), "myapp", map[string]interface{}{ | ||
| "path": "../escape.txt", | ||
| "content": "x", | ||
| }); err == nil { | ||
| t.Fatal("a path escaping the deployment directory must be refused") | ||
| } | ||
| } | ||
|
|
||
| func TestWriteDeploymentFileFailsClosedOnProtectedCheckError(t *testing.T) { | ||
| s, _, _ := setupPlanTestServer(t) | ||
| // No deployment is created, so the protected-mode check cannot load it and | ||
| // errors. A state-changing tool must refuse rather than proceed. | ||
| tool := s.aiToolRegistry()["write_deployment_file"] | ||
| _, err := tool.Run(s, toolCtx(nil), "ghost", map[string]interface{}{"path": "x", "content": "y"}) | ||
| if err == nil || !strings.Contains(err.Error(), "protected mode") { | ||
| t.Errorf("a failed protected-mode check must block the write, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestMutatingToolsRequireWriteAccess(t *testing.T) { | ||
| s, tmpDir, _ := setupPlanTestServer(t) | ||
| createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) | ||
|
|
||
| // A viewer with no write access to the deployment. | ||
| viewer := &auth.ActorContext{Role: auth.RoleViewer, User: &auth.User{ID: 1, Username: "v"}} | ||
| args := map[string]interface{}{"path": "x", "content": "y", "action_id": "a", "action": "restart"} | ||
| for _, name := range []string{"write_deployment_file", "run_quick_action", "control_deployment"} { | ||
| tool := s.aiToolRegistry()[name] | ||
| if _, err := tool.Run(s, toolCtx(viewer), "myapp", args); err == nil || !strings.Contains(err.Error(), "write access") { | ||
| t.Errorf("%s must require write access, got %v", name, err) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestGetSecurityEventsWhenDisabled(t *testing.T) { | ||
| s, tmpDir, _ := setupPlanTestServer(t) | ||
| createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) | ||
|
|
||
| tool := s.aiToolRegistry()["get_security_events"] | ||
| out, err := tool.Run(s, toolCtx(nil), "myapp", map[string]interface{}{}) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if !strings.Contains(out, "not enabled") { | ||
| t.Errorf("expected a disabled message, got %q", out) | ||
| } | ||
| } | ||
|
|
||
| func TestControlDeploymentRejectsBadAction(t *testing.T) { | ||
| s, tmpDir, _ := setupPlanTestServer(t) | ||
| createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) | ||
|
|
||
| tool := s.aiToolRegistry()["control_deployment"] | ||
| if _, err := tool.Run(s, toolCtx(nil), "myapp", map[string]interface{}{"action": "delete"}); err == nil || | ||
| !strings.Contains(err.Error(), "start, stop, or restart") { | ||
| t.Errorf("expected an invalid-action error, got %v", err) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Output from deployment control operations (like
start,stop, orrestart) often includes logs from the orchestration engine or the container's standard output. These logs can contain sensitive information such as environment variables, database connection strings, or internal system paths. Consistent with therun_quick_actiontool, this output should be passed through the redactor to ensure no secrets are exposed to the AI model or the session logs.