Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
20de8a8
add session-level auto-approve for AI file read operations
programista-wordpress Mar 21, 2026
03ae5bd
fix: handle Windows path separators in directory extraction
programista-wordpress Mar 21, 2026
0ab2969
fix: block sensitive directories from session auto-approval
programista-wordpress Mar 21, 2026
42a16e7
fix: canonicalize paths with symlink resolution to prevent bypass
programista-wordpress Mar 21, 2026
12f4861
feat: add MCP (Model Context Protocol) client package
programista-wordpress Mar 22, 2026
85eb4b0
feat: integrate MCP with AI chat pipeline
programista-wordpress Mar 22, 2026
f7f72c6
feat: MCP Context toggle and auto-detect in AI panel
programista-wordpress Mar 22, 2026
69c7e84
feat: MCP Client widget with tools panel and call log
programista-wordpress Mar 22, 2026
92c9603
feat: web content tools - read text, read HTML, SEO audit
programista-wordpress Mar 22, 2026
0e6a6bc
feat: session history - persist and display previous AI sessions
programista-wordpress Mar 22, 2026
dd7f3d8
feat: AI execution plans with progress tracking
programista-wordpress Mar 22, 2026
b4c8402
feat: project instructions reader (WAVE.md, CLAUDE.md, .cursorrules)
programista-wordpress Mar 22, 2026
b02b7f6
perf: compress tool descriptions and consolidate utility tools
programista-wordpress Mar 22, 2026
df51b85
fix: syntax highlighting in AI diff viewer
programista-wordpress Mar 22, 2026
7a0db93
feat: Quick Add Model with BYOK presets
programista-wordpress Mar 22, 2026
f6d1e4f
feat: graceful shutdown for MCP clients and session history save
programista-wordpress Mar 22, 2026
7df705f
fix: improve AI message handling and shell command detection
programista-wordpress Mar 22, 2026
54da2fc
docs: update README with MCP, web tools, plans, session history, and …
programista-wordpress Mar 22, 2026
ce1f244
feat: improve AI quality - project stack context, detailed plans, pro…
programista-wordpress Mar 22, 2026
7c39a6b
security: sanitize WebSelector opts in RPC handler
programista-wordpress Mar 22, 2026
e2b0558
fix: use error banner instead of API key input for Ollama connection …
programista-wordpress Mar 22, 2026
e189c5a
fix: only send mcpcwd when MCP context is enabled
programista-wordpress Mar 22, 2026
d1532e5
a11y: add switch role and aria-label to toggle buttons
programista-wordpress Mar 22, 2026
776abcb
fix: prevent panic on missing step_id in plan_update
programista-wordpress Mar 22, 2026
5aa0c87
fix: remove hardcoded tool names and approval language from system pr…
programista-wordpress Mar 22, 2026
e17cc55
fix: kill MCP process on read timeout to prevent goroutine leak
programista-wordpress Mar 22, 2026
4b32276
brand: introduce Wove - AI-first terminal built on Wave engine
programista-wordpress Mar 22, 2026
669c647
brand: rename to Wove - AI-first terminal
programista-wordpress Mar 22, 2026
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
78 changes: 75 additions & 3 deletions frontend/app/aipanel/aitooluse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,32 @@ const ToolDesc = memo(({ text, className }: ToolDescProps) => {

ToolDesc.displayName = "ToolDesc";

// Extract directory path from a tool description like: reading "/path/to/file" (...)
function extractDirFromToolDesc(toolDesc: string): string | null {
const match = toolDesc?.match(/(?:reading|reading directory)\s+"([^"]+)"/);
if (!match) return null;
const filePath = match[1];
// For "reading directory" — the path itself is the directory
if (toolDesc.startsWith("reading directory")) {
return filePath;
}
// For "reading" (file) — get parent directory
const lastSlash = filePath.lastIndexOf("/");
if (lastSlash < 0) return filePath;
if (lastSlash === 0) return "/";
return filePath.substring(0, lastSlash);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Extract all unique directories from a set of tool use parts
function extractDirsFromParts(parts: Array<WaveUIMessagePart & { type: "data-tooluse" }>): string[] {
const dirs = new Set<string>();
for (const part of parts) {
const dir = extractDirFromToolDesc(part.data.tooldesc);
if (dir) dirs.add(dir);
}
return Array.from(dirs);
}

function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string {
return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval;
}
Expand All @@ -85,9 +111,11 @@ interface AIToolApprovalButtonsProps {
count: number;
onApprove: () => void;
onDeny: () => void;
onAllowSession?: () => void;
showSessionButton?: boolean;
}

const AIToolApprovalButtons = memo(({ count, onApprove, onDeny }: AIToolApprovalButtonsProps) => {
const AIToolApprovalButtons = memo(({ count, onApprove, onDeny, onAllowSession, showSessionButton }: AIToolApprovalButtonsProps) => {
const approveText = count > 1 ? `Approve All (${count})` : "Approve";
const denyText = count > 1 ? "Deny All" : "Deny";

Expand All @@ -99,6 +127,15 @@ const AIToolApprovalButtons = memo(({ count, onApprove, onDeny }: AIToolApproval
>
{approveText}
</button>
{showSessionButton && onAllowSession && (
<button
onClick={onAllowSession}
className="px-3 py-1 border border-green-700 text-green-400 hover:border-green-500 hover:text-green-300 text-sm rounded cursor-pointer transition-colors"
title="Auto-approve all file reads under this directory for the rest of this session"
>
Allow reading in this session
</button>
)}
<button
onClick={onDeny}
className="px-3 py-1 border border-gray-600 text-gray-300 hover:border-gray-500 hover:text-white text-sm rounded cursor-pointer transition-colors"
Expand Down Expand Up @@ -165,6 +202,19 @@ const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => {
});
};

const handleAllowSession = () => {
const dirs = extractDirsFromParts(parts);
const model = WaveAIModel.getInstance();
for (const dir of dirs) {
model.sessionReadApprove(dir);
}
handleApprove();
};

const isReadOp = parts.some(
(p) => p.data.toolname === "read_text_file" || p.data.toolname === "read_dir"
);

return (
<div className="flex items-start gap-2 p-2 rounded bg-zinc-800/60 border border-zinc-700">
<div className="flex-1">
Expand All @@ -175,7 +225,13 @@ const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => {
))}
</div>
{effectiveApproval === "needs-approval" && (
<AIToolApprovalButtons count={parts.length} onApprove={handleApprove} onDeny={handleDeny} />
<AIToolApprovalButtons
count={parts.length}
onApprove={handleApprove}
onDeny={handleDeny}
onAllowSession={handleAllowSession}
showSessionButton={isReadOp}
/>
)}
</div>
</div>
Expand Down Expand Up @@ -215,6 +271,8 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
};
}, []);

const isReadTool = toolData.toolname === "read_text_file" || toolData.toolname === "read_dir";

const handleApprove = () => {
setUserApprovalOverride("user-approved");
WaveAIModel.getInstance().toolUseSendApproval(toolData.toolcallid, "user-approved");
Expand All @@ -225,6 +283,14 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
WaveAIModel.getInstance().toolUseSendApproval(toolData.toolcallid, "user-denied");
};

const handleAllowSession = () => {
const dir = extractDirFromToolDesc(toolData.tooldesc);
if (dir) {
WaveAIModel.getInstance().sessionReadApprove(dir);
}
handleApprove();
};

const handleMouseEnter = () => {
if (!toolData.blockid) return;

Expand Down Expand Up @@ -309,7 +375,13 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
)}
{effectiveApproval === "needs-approval" && (
<div className="pl-6">
<AIToolApprovalButtons count={1} onApprove={handleApprove} onDeny={handleDeny} />
<AIToolApprovalButtons
count={1}
onApprove={handleApprove}
onDeny={handleDeny}
onAllowSession={handleAllowSession}
showSessionButton={isReadTool}
/>
</div>
)}
{showRestoreModal && <RestoreBackupModal part={part} />}
Expand Down
6 changes: 6 additions & 0 deletions frontend/app/aipanel/waveai-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,12 @@ export class WaveAIModel {
});
}

sessionReadApprove(path: string) {
RpcApi.WaveAISessionReadApproveCommand(TabRpcClient, {
path: path,
});
}

async openDiff(fileName: string, toolcallid: string) {
const chatId = this.getChatId();

Expand Down
6 changes: 6 additions & 0 deletions frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,12 @@ export class RpcApiType {
return client.wshRpcCall("waveaigettooldiff", data, opts);
}

// command "waveaisessionreadapprove" [call]
WaveAISessionReadApproveCommand(client: WshClient, data: CommandWaveAISessionReadApproveData, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaisessionreadapprove", data, opts);
return client.wshRpcCall("waveaisessionreadapprove", data, opts);
}

// command "waveaitoolapprove" [call]
WaveAIToolApproveCommand(client: WshClient, data: CommandWaveAIToolApproveData, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaitoolapprove", data, opts);
Expand Down
5 changes: 5 additions & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,11 @@ declare global {
modifiedcontents64: string;
};

// wshrpc.CommandWaveAISessionReadApproveData
type CommandWaveAISessionReadApproveData = {
path: string;
};

// wshrpc.CommandWaveAIToolApproveData
type CommandWaveAIToolApproveData = {
toolcallid: string;
Expand Down
72 changes: 72 additions & 0 deletions pkg/aiusechat/sessionapproval.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package aiusechat

import (
"path/filepath"
"strings"
"sync"

"github.com/wavetermdev/waveterm/pkg/util/logutil"
"github.com/wavetermdev/waveterm/pkg/wavebase"
)

// SessionApprovalRegistry tracks paths that the user has approved for reading
// during the current session. This is in-memory only and resets when the app restarts.
type SessionApprovalRegistry struct {
mu sync.RWMutex
approvedPaths map[string]bool // set of approved directory prefixes
}

var globalSessionApproval = &SessionApprovalRegistry{
approvedPaths: make(map[string]bool),
}

// AddSessionReadApproval adds a directory path to the session-level read approval list.
// All files under this directory (and subdirectories) will be auto-approved for reading.
func AddSessionReadApproval(dirPath string) {
expanded, err := wavebase.ExpandHomeDir(dirPath)
if err != nil {
expanded = dirPath
}
cleaned := filepath.Clean(expanded)
if !strings.HasSuffix(cleaned, string(filepath.Separator)) {
cleaned += string(filepath.Separator)
}
logutil.DevPrintf("session read approval added: %s\n", cleaned)
globalSessionApproval.mu.Lock()
defer globalSessionApproval.mu.Unlock()
globalSessionApproval.approvedPaths[cleaned] = true
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// IsSessionReadApproved checks if a file path falls under any session-approved directory.
func IsSessionReadApproved(filePath string) bool {
cleaned := filepath.Clean(filePath)
globalSessionApproval.mu.RLock()
defer globalSessionApproval.mu.RUnlock()
for approvedDir := range globalSessionApproval.approvedPaths {
if strings.HasPrefix(cleaned, approvedDir) || cleaned == strings.TrimSuffix(approvedDir, string(filepath.Separator)) {
return true
}
}
return false
}

// GetSessionApprovedPaths returns a copy of all currently approved paths.
func GetSessionApprovedPaths() []string {
globalSessionApproval.mu.RLock()
defer globalSessionApproval.mu.RUnlock()
paths := make([]string, 0, len(globalSessionApproval.approvedPaths))
for p := range globalSessionApproval.approvedPaths {
paths = append(paths, p)
}
return paths
}

// ClearSessionApprovals removes all session-level read approvals.
func ClearSessionApprovals() {
globalSessionApproval.mu.Lock()
defer globalSessionApproval.mu.Unlock()
globalSessionApproval.approvedPaths = make(map[string]bool)
}
11 changes: 11 additions & 0 deletions pkg/aiusechat/tools_readdir.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,17 @@ func GetReadDirToolDefinition() uctypes.ToolDefinition {
},
ToolAnyCallback: readDirCallback,
ToolApproval: func(input any) string {
parsed, err := parseReadDirInput(input)
if err != nil {
return uctypes.ApprovalNeedsApproval
}
expandedPath, err := wavebase.ExpandHomeDir(parsed.Path)
if err != nil {
return uctypes.ApprovalNeedsApproval
}
if IsSessionReadApproved(expandedPath) {
return uctypes.ApprovalAutoApproved
}
return uctypes.ApprovalNeedsApproval
Comment thread
mits-pl marked this conversation as resolved.
},
ToolVerifyInput: verifyReadDirInput,
Expand Down
11 changes: 11 additions & 0 deletions pkg/aiusechat/tools_readfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,17 @@ func GetReadTextFileToolDefinition() uctypes.ToolDefinition {
},
ToolAnyCallback: readTextFileCallback,
ToolApproval: func(input any) string {
parsed, err := parseReadTextFileInput(input)
if err != nil {
return uctypes.ApprovalNeedsApproval
}
expandedPath, err := wavebase.ExpandHomeDir(parsed.Filename)
if err != nil {
return uctypes.ApprovalNeedsApproval
}
if IsSessionReadApproved(expandedPath) {
return uctypes.ApprovalAutoApproved
}
return uctypes.ApprovalNeedsApproval
Comment thread
mits-pl marked this conversation as resolved.
},
ToolVerifyInput: verifyReadTextFileInput,
Expand Down
6 changes: 6 additions & 0 deletions pkg/wshrpc/wshclient/wshclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,12 @@ func WaveAIGetToolDiffCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIGetToo
return resp, err
}

// command "waveaisessionreadapprove", wshserver.WaveAISessionReadApproveCommand
func WaveAISessionReadApproveCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAISessionReadApproveData, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "waveaisessionreadapprove", data, opts)
return err
}

// command "waveaitoolapprove", wshserver.WaveAIToolApproveCommand
func WaveAIToolApproveCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIToolApproveData, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "waveaitoolapprove", data, opts)
Expand Down
5 changes: 5 additions & 0 deletions pkg/wshrpc/wshrpctypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ type WshRpcInterface interface {
GetWaveAIChatCommand(ctx context.Context, data CommandGetWaveAIChatData) (*uctypes.UIChat, error)
GetWaveAIRateLimitCommand(ctx context.Context) (*uctypes.RateLimitInfo, error)
WaveAIToolApproveCommand(ctx context.Context, data CommandWaveAIToolApproveData) error
WaveAISessionReadApproveCommand(ctx context.Context, data CommandWaveAISessionReadApproveData) error
WaveAIAddContextCommand(ctx context.Context, data CommandWaveAIAddContextData) error
WaveAIGetToolDiffCommand(ctx context.Context, data CommandWaveAIGetToolDiffData) (*CommandWaveAIGetToolDiffRtnData, error)

Expand Down Expand Up @@ -548,6 +549,10 @@ type CommandWaveAIToolApproveData struct {
Approval string `json:"approval,omitempty"`
}

type CommandWaveAISessionReadApproveData struct {
Path string `json:"path"`
}

type AIAttachedFile struct {
Name string `json:"name"`
Type string `json:"type"`
Expand Down
8 changes: 8 additions & 0 deletions pkg/wshrpc/wshserver/wshserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,14 @@ func (ws *WshServer) WaveAIToolApproveCommand(ctx context.Context, data wshrpc.C
return aiusechat.UpdateToolApproval(data.ToolCallId, data.Approval)
}

func (ws *WshServer) WaveAISessionReadApproveCommand(ctx context.Context, data wshrpc.CommandWaveAISessionReadApproveData) error {
if data.Path == "" {
return fmt.Errorf("path is required")
}
aiusechat.AddSessionReadApproval(data.Path)
return nil
}

func (ws *WshServer) WaveAIGetToolDiffCommand(ctx context.Context, data wshrpc.CommandWaveAIGetToolDiffData) (*wshrpc.CommandWaveAIGetToolDiffRtnData, error) {
originalContent, modifiedContent, err := aiusechat.CreateWriteTextFileDiff(ctx, data.ChatId, data.ToolCallId)
if err != nil {
Expand Down