feat: implement permission tool usage from frontend to backend
Complete end-to-end permission flow for tool execution: - Add POST /api/permission/grant endpoint with gateway proxy - Add permission checks for list_dir, edit_file, append_file tools - Fix frontend PermissionPrompt to call grant API instead of chat messages - Add grantPermission() to frontend API client - Expose PermissionCache via AgentInstance for API access - Wire up permission grant callback through gateway to agent loop Inspired by opencode's permission system architecture.
This commit is contained in:
parent
9870f46180
commit
1fc04f7c77
13 changed files with 516 additions and 26 deletions
|
|
@ -3,6 +3,8 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -29,6 +31,22 @@ func (al *AgentLoop) GetRegistry() *AgentRegistry {
|
||||||
return al.registry
|
return al.registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantPermission grants permission for a path to a specific agent.
|
||||||
|
// agentID is the normalized agent ID (e.g. "main"), path is the file path,
|
||||||
|
// duration is "once" or "session".
|
||||||
|
// Returns an error if the agent is not found or the permission cache is not initialized.
|
||||||
|
func (al *AgentLoop) GrantPermission(agentID, path, duration string) error {
|
||||||
|
registry := al.GetRegistry()
|
||||||
|
if registry == nil {
|
||||||
|
return fmt.Errorf("agent registry not initialized")
|
||||||
|
}
|
||||||
|
agent, ok := registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("agent %q not found", agentID)
|
||||||
|
}
|
||||||
|
return agent.GrantPermission(path, duration)
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) GetConfig() *config.Config {
|
func (al *AgentLoop) GetConfig() *config.Config {
|
||||||
al.mu.RLock()
|
al.mu.RLock()
|
||||||
defer al.mu.RUnlock()
|
defer al.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ type AgentInstance struct {
|
||||||
Sessions session.SessionStore
|
Sessions session.SessionStore
|
||||||
ContextBuilder *ContextBuilder
|
ContextBuilder *ContextBuilder
|
||||||
Tools *tools.ToolRegistry
|
Tools *tools.ToolRegistry
|
||||||
|
PermissionCache *tools.PermissionCache // Exported for permission grant API
|
||||||
Subagents *config.SubagentsConfig
|
Subagents *config.SubagentsConfig
|
||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
@ -255,6 +256,7 @@ func NewAgentInstance(
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
Tools: toolsRegistry,
|
Tools: toolsRegistry,
|
||||||
|
PermissionCache: permissionCache, // Store reference for API access
|
||||||
Subagents: subagents,
|
Subagents: subagents,
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
|
|
@ -374,6 +376,20 @@ func (a *AgentInstance) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantPermission grants permission for a path with the specified duration.
|
||||||
|
// Duration can be "once" or "session".
|
||||||
|
// Returns an error if the PermissionCache is not initialized.
|
||||||
|
func (a *AgentInstance) GrantPermission(path, duration string) error {
|
||||||
|
if a.PermissionCache == nil {
|
||||||
|
return fmt.Errorf("permission cache not initialized for agent %s", a.ID)
|
||||||
|
}
|
||||||
|
if duration != "once" && duration != "session" {
|
||||||
|
return fmt.Errorf("invalid duration %q: must be 'once' or 'session'", duration)
|
||||||
|
}
|
||||||
|
a.PermissionCache.Grant(path, duration)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// initSessionStore creates the session persistence backend.
|
// initSessionStore creates the session persistence backend.
|
||||||
// It uses the JSONL store by default and auto-migrates legacy JSON sessions.
|
// It uses the JSONL store by default and auto-migrates legacy JSON sessions.
|
||||||
// Falls back to SessionManager if the JSONL store cannot be initialized or
|
// Falls back to SessionManager if the JSONL store cannot be initialized or
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,10 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("list_dir tool not registered")
|
t.Fatal("list_dir tool not registered")
|
||||||
}
|
}
|
||||||
|
// Grant permission for media temp dir if permission cache exists
|
||||||
|
if pc, ok := agent.PermissionCache.Check(mediaDir); ok && pc == "" {
|
||||||
|
agent.PermissionCache.Grant(mediaDir, "session")
|
||||||
|
}
|
||||||
listResult := listTool.Execute(context.Background(), map[string]any{"path": mediaDir})
|
listResult := listTool.Execute(context.Background(), map[string]any{"path": mediaDir})
|
||||||
if listResult.IsError {
|
if listResult.IsError {
|
||||||
t.Fatalf("list_dir should allow media temp dir, got: %s", listResult.ForLLM)
|
t.Fatalf("list_dir should allow media temp dir, got: %s", listResult.ForLLM)
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
|
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
|
||||||
agentLoop.SetReloadFunc(reloadTrigger)
|
agentLoop.SetReloadFunc(reloadTrigger)
|
||||||
|
|
||||||
|
// Wire permission grant function to allow runtime permission grants via health endpoint
|
||||||
|
runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error {
|
||||||
|
return agentLoop.GrantPermission(agentID, path, duration)
|
||||||
|
})
|
||||||
|
|
||||||
for _, bindHost := range listenResult.BindHosts {
|
for _, bindHost := range listenResult.BindHosts {
|
||||||
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))
|
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ type Server struct {
|
||||||
checks map[string]Check
|
checks map[string]Check
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
reloadFunc func() error
|
reloadFunc func() error
|
||||||
|
permissionGrantFunc func(agentID, path, duration string) error
|
||||||
authToken string // optional bearer token for protected endpoints
|
authToken string // optional bearer token for protected endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ func NewServer(host string, port int, token string) *Server {
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
mux.HandleFunc("/reload", s.reloadHandler)
|
mux.HandleFunc("/reload", s.reloadHandler)
|
||||||
|
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
|
||||||
|
|
||||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
|
|
@ -119,6 +121,80 @@ func (s *Server) SetReloadFunc(fn func() error) {
|
||||||
s.reloadFunc = fn
|
s.reloadFunc = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPermissionGrantFunc sets the callback function for granting permissions.
|
||||||
|
func (s *Server) SetPermissionGrantFunc(fn func(agentID, path, duration string) error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.permissionGrantFunc = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionGrantHandler handles POST /internal/permission/grant requests.
|
||||||
|
func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token check
|
||||||
|
s.mu.RLock()
|
||||||
|
requiredToken := s.authToken
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if requiredToken != "" {
|
||||||
|
given := extractBearerToken(r.Header.Get("Authorization"))
|
||||||
|
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode request body
|
||||||
|
var req struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid JSON: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AgentID == "" || req.Path == "" || req.Duration == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "agent_id, path, and duration are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
grantFunc := s.permissionGrantFunc
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if grantFunc == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "permission grant not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := grantFunc(req.AgentID, req.Path, req.Duration); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EditFileTool edits a file by replacing old_text with new_text.
|
// EditFileTool edits a file by replacing old_text with new_text.
|
||||||
|
|
@ -89,6 +91,25 @@ func (t *EditFileTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *EditFileTool) checkPermission(path string) string {
|
||||||
|
if !t.restrictToWorkspace || !t.askPermission || t.permissionCache == nil {
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !t.isOutsideWorkspace(path) {
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
if perm := t.permissionCache.Check(path); perm != "" {
|
||||||
|
if perm == "denied" {
|
||||||
|
return "denied"
|
||||||
|
}
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "needs_permission"
|
||||||
|
}
|
||||||
|
|
||||||
func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -105,17 +126,16 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return ErrorResult("new_text is required")
|
return ErrorResult("new_text is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permission for paths outside workspace
|
switch t.checkPermission(path) {
|
||||||
if t.askPermission && t.restrictToWorkspace && t.isOutsideWorkspace(path) {
|
case "needs_permission":
|
||||||
if perm := t.permissionCache.Check(path); perm == "" {
|
logger.InfoCF("edit_file", "Permission needed", map[string]any{"path": path})
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", path, path),
|
ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", path, path),
|
||||||
ForUser: fmt.Sprintf("Permission required to edit %s", path),
|
ForUser: fmt.Sprintf("⚠️ Permission required to edit %s", path),
|
||||||
}
|
}
|
||||||
} else if perm == "denied" {
|
case "denied":
|
||||||
return ErrorResult(fmt.Sprintf("Access to %s was denied", path))
|
return ErrorResult(fmt.Sprintf("Access to %s was denied", path))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if err := editFile(t.fs, path, oldText, newText); err != nil {
|
if err := editFile(t.fs, path, oldText, newText); err != nil {
|
||||||
return ErrorResult(err.Error())
|
return ErrorResult(err.Error())
|
||||||
|
|
@ -125,7 +145,10 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
|
|
||||||
type AppendFileTool struct {
|
type AppendFileTool struct {
|
||||||
fs fileSystem
|
fs fileSystem
|
||||||
|
workspace string
|
||||||
|
restrictToWorkspace bool
|
||||||
permissionCache interface{ Check(path string) string }
|
permissionCache interface{ Check(path string) string }
|
||||||
|
askPermission bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAppendFileTool(workspace string, restrict bool, permCache any, allowPaths ...[]*regexp.Regexp) *AppendFileTool {
|
func NewAppendFileTool(workspace string, restrict bool, permCache any, allowPaths ...[]*regexp.Regexp) *AppendFileTool {
|
||||||
|
|
@ -133,12 +156,49 @@ func NewAppendFileTool(workspace string, restrict bool, permCache any, allowPath
|
||||||
if len(allowPaths) > 0 {
|
if len(allowPaths) > 0 {
|
||||||
patterns = allowPaths[0]
|
patterns = allowPaths[0]
|
||||||
}
|
}
|
||||||
|
askPerm := false
|
||||||
|
var cache interface{ Check(path string) string }
|
||||||
|
if permCache != nil {
|
||||||
|
cache = permCache.(interface{ Check(path string) string })
|
||||||
|
askPerm = true
|
||||||
|
}
|
||||||
return &AppendFileTool{
|
return &AppendFileTool{
|
||||||
fs: buildFs(workspace, restrict, patterns),
|
fs: buildFs(workspace, restrict, patterns),
|
||||||
permissionCache: permCache.(interface{ Check(path string) string }),
|
workspace: workspace,
|
||||||
|
restrictToWorkspace: restrict,
|
||||||
|
permissionCache: cache,
|
||||||
|
askPermission: askPerm,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *AppendFileTool) isOutsideWorkspace(path string) bool {
|
||||||
|
if t.workspace == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
absWorkspace, _ := filepath.Abs(t.workspace)
|
||||||
|
absPath, _ := filepath.Abs(path)
|
||||||
|
return !strings.HasPrefix(absPath, absWorkspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AppendFileTool) checkPermission(path string) string {
|
||||||
|
if !t.restrictToWorkspace || !t.askPermission || t.permissionCache == nil {
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !t.isOutsideWorkspace(path) {
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
if perm := t.permissionCache.Check(path); perm != "" {
|
||||||
|
if perm == "denied" {
|
||||||
|
return "denied"
|
||||||
|
}
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "needs_permission"
|
||||||
|
}
|
||||||
|
|
||||||
func (t *AppendFileTool) Name() string {
|
func (t *AppendFileTool) Name() string {
|
||||||
return "append_file"
|
return "append_file"
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +230,17 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
|
||||||
return ErrorResult("path is required")
|
return ErrorResult("path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch t.checkPermission(path) {
|
||||||
|
case "needs_permission":
|
||||||
|
logger.InfoCF("append_file", "Permission needed", map[string]any{"path": path})
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", path, path),
|
||||||
|
ForUser: fmt.Sprintf("⚠️ Permission required to append to %s", path),
|
||||||
|
}
|
||||||
|
case "denied":
|
||||||
|
return ErrorResult(fmt.Sprintf("Access to %s was denied", path))
|
||||||
|
}
|
||||||
|
|
||||||
content, ok := args["content"].(string)
|
content, ok := args["content"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("content is required")
|
return ErrorResult("content is required")
|
||||||
|
|
|
||||||
|
|
@ -993,7 +993,10 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
||||||
|
|
||||||
type ListDirTool struct {
|
type ListDirTool struct {
|
||||||
fs fileSystem
|
fs fileSystem
|
||||||
|
workspace string
|
||||||
|
restrictToWorkspace bool
|
||||||
permissionCache interface{ Check(path string) string }
|
permissionCache interface{ Check(path string) string }
|
||||||
|
askPermission bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListDirTool(workspace string, restrict bool, permCache any, allowPaths ...[]*regexp.Regexp) *ListDirTool {
|
func NewListDirTool(workspace string, restrict bool, permCache any, allowPaths ...[]*regexp.Regexp) *ListDirTool {
|
||||||
|
|
@ -1001,12 +1004,49 @@ func NewListDirTool(workspace string, restrict bool, permCache any, allowPaths .
|
||||||
if len(allowPaths) > 0 {
|
if len(allowPaths) > 0 {
|
||||||
patterns = allowPaths[0]
|
patterns = allowPaths[0]
|
||||||
}
|
}
|
||||||
|
askPerm := false
|
||||||
|
var cache interface{ Check(path string) string }
|
||||||
|
if permCache != nil {
|
||||||
|
cache = permCache.(interface{ Check(path string) string })
|
||||||
|
askPerm = true
|
||||||
|
}
|
||||||
return &ListDirTool{
|
return &ListDirTool{
|
||||||
fs: buildFs(workspace, restrict, patterns),
|
fs: buildFs(workspace, restrict, patterns),
|
||||||
permissionCache: permCache.(interface{ Check(path string) string }),
|
workspace: workspace,
|
||||||
|
restrictToWorkspace: restrict,
|
||||||
|
permissionCache: cache,
|
||||||
|
askPermission: askPerm,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ListDirTool) isOutsideWorkspace(path string) bool {
|
||||||
|
if t.workspace == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
absWorkspace, _ := filepath.Abs(t.workspace)
|
||||||
|
absPath, _ := filepath.Abs(path)
|
||||||
|
return !strings.HasPrefix(absPath, absWorkspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ListDirTool) checkPermission(path string) string {
|
||||||
|
if !t.restrictToWorkspace || !t.askPermission || t.permissionCache == nil {
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !t.isOutsideWorkspace(path) {
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
if perm := t.permissionCache.Check(path); perm != "" {
|
||||||
|
if perm == "denied" {
|
||||||
|
return "denied"
|
||||||
|
}
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "needs_permission"
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ListDirTool) Name() string {
|
func (t *ListDirTool) Name() string {
|
||||||
return "list_dir"
|
return "list_dir"
|
||||||
}
|
}
|
||||||
|
|
@ -1034,6 +1074,27 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||||
path = "."
|
path = "."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve path for permission check (use workspace path for "." to check sandbox boundary)
|
||||||
|
checkPath := path
|
||||||
|
if path == "." {
|
||||||
|
if abs, err := filepath.Abs(path); err == nil {
|
||||||
|
checkPath = abs
|
||||||
|
} else if t.workspace != "" {
|
||||||
|
checkPath = t.workspace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t.checkPermission(checkPath) {
|
||||||
|
case "needs_permission":
|
||||||
|
logger.InfoCF("list_dir", "Permission needed", map[string]any{"path": checkPath})
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", checkPath, checkPath),
|
||||||
|
ForUser: fmt.Sprintf("⚠️ Permission required to list %s", checkPath),
|
||||||
|
}
|
||||||
|
case "denied":
|
||||||
|
return ErrorResult(fmt.Sprintf("Access to %s was denied", checkPath))
|
||||||
|
}
|
||||||
|
|
||||||
entries, err := t.fs.ReadDir(path)
|
entries, err := t.fs.ReadDir(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
|
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
picotools "github.com/sipeed/picoclaw/pkg/tools"
|
picotools "github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -196,6 +200,7 @@ func (h *Handler) registerToolRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
|
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
|
||||||
mux.HandleFunc("GET /api/tools/web-search-config", h.handleGetWebSearchConfig)
|
mux.HandleFunc("GET /api/tools/web-search-config", h.handleGetWebSearchConfig)
|
||||||
mux.HandleFunc("PUT /api/tools/web-search-config", h.handleUpdateWebSearchConfig)
|
mux.HandleFunc("PUT /api/tools/web-search-config", h.handleUpdateWebSearchConfig)
|
||||||
|
mux.HandleFunc("POST /api/permission/grant", h.handleGrantPermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -672,3 +677,82 @@ func resolveCurrentWebSearchProvider(cfg *config.Config) string {
|
||||||
}
|
}
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGrantPermission proxies permission grant requests to the gateway's internal endpoint.
|
||||||
|
// Frontend sends POST /api/permission/grant with { "path": "...", "duration": "once"|"session" }
|
||||||
|
// We add the default agent ID ("main") and forward to the gateway's /internal/permission/grant.
|
||||||
|
//
|
||||||
|
// POST /api/permission/grant
|
||||||
|
func (h *Handler) handleGrantPermission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.gatewayAvailableForProxy() {
|
||||||
|
logger.Warnf("Gateway not available for permission grant proxy")
|
||||||
|
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse incoming request body from frontend
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if req.Path == "" || req.Duration == "" {
|
||||||
|
http.Error(w, "path and duration are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Duration != "once" && req.Duration != "session" {
|
||||||
|
http.Error(w, "duration must be 'once' or 'session'", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gateway auth token from pid data
|
||||||
|
gateway.mu.Lock()
|
||||||
|
pidData := gateway.pidData
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
if pidData == nil || pidData.Token == "" {
|
||||||
|
logger.Warnf("Gateway auth token not available for permission grant")
|
||||||
|
http.Error(w, "Gateway auth token not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authToken := pidData.Token
|
||||||
|
|
||||||
|
// Prepare request body for gateway's internal endpoint (requires agent_id)
|
||||||
|
grantReq := struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}{
|
||||||
|
AgentID: "main", // Default to main agent
|
||||||
|
Path: req.Path,
|
||||||
|
Duration: req.Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reverse proxy to gateway's internal permission grant endpoint
|
||||||
|
target := h.gatewayProxyURL()
|
||||||
|
target.Path = "/internal/permission/grant"
|
||||||
|
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||||
|
pr.SetURL(target)
|
||||||
|
// Inject auth token for gateway health server
|
||||||
|
pr.Out.Header.Set("Authorization", "Bearer "+authToken)
|
||||||
|
// Replace request body with the modified payload that includes agent_id
|
||||||
|
bodyBytes, _ := json.Marshal(grantReq)
|
||||||
|
pr.Out.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
pr.Out.ContentLength = int64(len(bodyBytes))
|
||||||
|
pr.Out.Header.Set("Content-Type", "application/json")
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
logger.Errorf("Failed to proxy permission grant request: %v", err)
|
||||||
|
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
|
||||||
1
web/frontend/.npmrc
Normal file
1
web/frontend/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://registry.npmmirror.com/
|
||||||
|
|
@ -95,3 +95,26 @@ export async function updateWebSearchConfig(
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GrantPermissionRequest {
|
||||||
|
path: string
|
||||||
|
command: string
|
||||||
|
duration: "once" | "session"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrantPermissionResponse {
|
||||||
|
status: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantPermission(
|
||||||
|
path: string,
|
||||||
|
command: string,
|
||||||
|
duration: "once" | "session"
|
||||||
|
): Promise<GrantPermissionResponse> {
|
||||||
|
return request<GrantPermissionResponse>("/api/permission/grant", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path, command, duration }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
111
web/frontend/src/components/permission/PermissionPrompt.tsx
Normal file
111
web/frontend/src/components/permission/PermissionPrompt.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { grantPermission } from "@/api/tools";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface PermissionPromptProps {
|
||||||
|
onPermissionGranted: (duration: "once" | "session") => void;
|
||||||
|
onPermissionDenied: () => void;
|
||||||
|
toolName: string;
|
||||||
|
path: string;
|
||||||
|
originalCommand: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionPrompt({
|
||||||
|
onPermissionGranted,
|
||||||
|
onPermissionDenied,
|
||||||
|
toolName,
|
||||||
|
path,
|
||||||
|
originalCommand,
|
||||||
|
}: PermissionPromptProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handlePermissionResponse = async (response: "once" | "session" | "no") => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (response === "no") {
|
||||||
|
onPermissionDenied();
|
||||||
|
toast.error(t("permissionDenied", { path }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant permission via API
|
||||||
|
await grantPermission(path, originalCommand, response);
|
||||||
|
|
||||||
|
// Notify the caller to update state
|
||||||
|
onPermissionGranted(response);
|
||||||
|
toast.success(
|
||||||
|
response === "once"
|
||||||
|
? t("permissionGrantedOnce", { path })
|
||||||
|
: t("permissionGrantedSession", { path })
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Permission error:", error);
|
||||||
|
toast.error(t("permissionError"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-border/10 border border-border/20 rounded-lg p-4 mb-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{/* Warning icon */}
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center bg-yellow-100 text-yellow-800 rounded-full">
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-yellow-800">{t("permissionRequiredTitle")}</h3>
|
||||||
|
<p className="mt-1 text-sm text-yellow-700">
|
||||||
|
{t("permissionRequiredMessage", {
|
||||||
|
tool: toolName,
|
||||||
|
path,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{originalCommand && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="font-medium text-yellow-800">{t("commandToExecute")}</p>
|
||||||
|
<pre className="mt-1 bg-yellow-50 p-2 rounded text-xs font-mono break-all">
|
||||||
|
{originalCommand}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePermissionResponse("no")}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t("permissionDeny")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePermissionResponse("once")}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t("permissionOnce")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePermissionResponse("session")}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t("permissionSession")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,16 @@
|
||||||
"websocketDisconnected": "Unable to chat: WebSocket connection is disconnected. Check network and gateway status, then refresh the page or restart Launcher.",
|
"websocketDisconnected": "Unable to chat: WebSocket connection is disconnected. Check network and gateway status, then refresh the page or restart Launcher.",
|
||||||
"websocketError": "Unable to chat: WebSocket connection failed. Check network and gateway status, then retry.",
|
"websocketError": "Unable to chat: WebSocket connection failed. Check network and gateway status, then retry.",
|
||||||
"noDefaultModel": "Unable to chat: No default model is selected. Set a default model on the Models page."
|
"noDefaultModel": "Unable to chat: No default model is selected. Set a default model on the Models page."
|
||||||
|
"permissionRequiredTitle": "Permission Required",
|
||||||
|
"permissionRequiredMessage": "PicoClaw wants to use {{tool}} to access {{path}} which is outside your workspace.",
|
||||||
|
"commandToExecute": "Command to execute:",
|
||||||
|
"permissionDeny": "Deny",
|
||||||
|
"permissionOnce": "Allow once",
|
||||||
|
"permissionSession": "Allow for session",
|
||||||
|
"permissionGrantedOnce": "Permission granted for one-time access to {{path}}",
|
||||||
|
"permissionGrantedSession": "Permission granted for session access to {{path}}",
|
||||||
|
"permissionDenied": "Permission denied for {{path}}",
|
||||||
|
"permissionError": "An error occurred while handling the permission request.",
|
||||||
},
|
},
|
||||||
"newChat": "New Chat",
|
"newChat": "New Chat",
|
||||||
"notConnected": "Gateway is not running. Start it to chat.",
|
"notConnected": "Gateway is not running. Start it to chat.",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,16 @@
|
||||||
"websocketDisconnected": "无法对话:WebSocket 连接已断开。请检查网络与服务状态,然后刷新页面或重启 Launcher。",
|
"websocketDisconnected": "无法对话:WebSocket 连接已断开。请检查网络与服务状态,然后刷新页面或重启 Launcher。",
|
||||||
"websocketError": "无法对话:WebSocket 连接失败。请检查网络与服务状态后重试。",
|
"websocketError": "无法对话:WebSocket 连接失败。请检查网络与服务状态后重试。",
|
||||||
"noDefaultModel": "无法对话:尚未设置默认模型。请前往模型页面设置默认模型。"
|
"noDefaultModel": "无法对话:尚未设置默认模型。请前往模型页面设置默认模型。"
|
||||||
|
"permissionRequiredTitle": "需要权限",
|
||||||
|
"permissionRequiredMessage": "PicoClaw 想要使用 {{tool}} 访问 {{path}},但该路径位于工作区之外。",
|
||||||
|
"commandToExecute": "要执行的命令:",
|
||||||
|
"permissionDeny": "拒绝",
|
||||||
|
"permissionOnce": "仅允许一次",
|
||||||
|
"permissionSession": "允许整个会话",
|
||||||
|
"permissionGrantedOnce": "已授予对 {{path}} 的一次性访问权限",
|
||||||
|
"permissionGrantedSession": "已授予对 {{path}} 的会话访问权限",
|
||||||
|
"permissionDenied": "已拒绝对 {{path}} 的访问权限",
|
||||||
|
"permissionError": "处理权限请求时发生错误。",
|
||||||
},
|
},
|
||||||
"newChat": "新建对话",
|
"newChat": "新建对话",
|
||||||
"notConnected": "服务未运行,请先启动以进行对话。",
|
"notConnected": "服务未运行,请先启动以进行对话。",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue