Skip to content

Commit 99629a7

Browse files
mjudeikisclaude
andauthored
fix(app-studio): persist write approval until the next commit (#377)
Approving a write prompt previously ran only that single tool call and recorded nothing, so each subsequent edit re-prompted ("This action will modify files in the App Studio workspace."). The only durable grant was the model-supplied plan envelope, which direct writes bypass. A direct write approval now records a blanket write grant (AllowAllWrites) that authorizes every workspace write on any path until the next commit, persisted across turns via the existing approved-plan store. commit clears it and new turns reload it, so one approval covers the whole edit cycle. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 38c563d commit 99629a7

6 files changed

Lines changed: 154 additions & 4 deletions

File tree

providers/app-studio/api/assistant_approved_plan.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,6 @@ func mergeProjectAssistantApprovedPlans(existing, next projectAssistantApprovedP
109109
merged := next
110110
merged.TargetPaths = normalizeProjectAssistantStringList(append(append([]string(nil), existing.TargetPaths...), next.TargetPaths...))
111111
merged.Operations = normalizeProjectAssistantStringList(append(append([]string(nil), existing.Operations...), next.Operations...))
112+
merged.AllowAllWrites = existing.AllowAllWrites || next.AllowAllWrites
112113
return merged
113114
}

providers/app-studio/api/assistant_eino_state.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ type projectAssistantApprovedPlan struct {
3030
AcceptanceCriteria []string `json:"acceptanceCriteria,omitempty"`
3131
ApprovedAt time.Time `json:"approvedAt,omitempty"`
3232
ApprovalTool string `json:"approvalTool,omitempty"`
33+
// AllowAllWrites grants every workspace write tool, on any path, until the
34+
// next commit. It is set when the user approves a write prompt directly (as
35+
// opposed to a model-supplied plan envelope), so a single "Allow" does not
36+
// re-prompt for each subsequent edit.
37+
AllowAllWrites bool `json:"allowAllWrites,omitempty"`
3338
}
3439

3540
type projectEinoAssistantRunState struct {

providers/app-studio/api/assistant_eino_tool.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,32 @@ func (t projectEinoAssistantTool) invokeApprovedPlanTool(ctx context.Context, ca
382382
return result
383383
}
384384

385+
// grantAllWritesUntilCommit records a blanket write grant after the user
386+
// approves a write prompt directly, so subsequent edits do not re-prompt until
387+
// the next commit retires the grant. It merges with any active plan envelope
388+
// and persists best effort: a failed write only means the user is re-prompted.
389+
func (t projectEinoAssistantTool) grantAllWritesUntilCommit(ctx context.Context) {
390+
plan := normalizeProjectAssistantApprovedPlan(projectAssistantApprovedPlan{
391+
Summary: "User approved workspace writes until the next commit.",
392+
Operations: []string{projectToolWriteFile, projectToolApplyPatch, projectToolMkdir},
393+
AllowAllWrites: true,
394+
ApprovalTool: "permission_allow_write",
395+
})
396+
if existing := t.runState.ApprovedPlan(); existing != nil {
397+
plan = mergeProjectAssistantApprovedPlans(*existing, plan)
398+
}
399+
t.runState.ApprovePlan(plan)
400+
stored := t.runState.ApprovedPlan()
401+
if stored == nil {
402+
return
403+
}
404+
persistCtx, cancelPersist := detachedProjectPersistenceContext(ctx)
405+
defer cancelPersist()
406+
if err := t.server.saveProjectAssistantApprovedPlan(persistCtx, t.req.MessageScope, stored); err != nil {
407+
klog.FromContext(ctx).Error(err, "persist App Studio write grant", "project", t.req.MessageScope.ProjectName)
408+
}
409+
}
410+
385411
func (t projectEinoAssistantTool) appendBuilderEvent(eventType string) {
386412
emitProjectAssistantBuilderEvent(t.req.StreamCallbacks, projectAssistantBuilderEventView(eventType))
387413
}
@@ -440,6 +466,11 @@ func (t projectEinoAssistantTool) resumePermission(ctx context.Context, callID s
440466
if data.EditedArguments != nil {
441467
args = cloneProjectAssistantToolArguments(data.EditedArguments)
442468
}
469+
if spec.Risk == projectAssistantToolRiskWrite {
470+
// The user approved a write directly. Remember it as a blanket write
471+
// grant until the next commit so each later edit does not re-prompt.
472+
t.grantAllWritesUntilCommit(ctx)
473+
}
443474
return t.invokeAllowedTool(ctx, callID, spec, args)
444475
case projectAssistantPermissionDeny:
445476
return t.finishDeniedToolCall(callID, name, args, "denied by user"), nil

providers/app-studio/api/assistant_permission.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func projectAssistantPermissionForToolWithRunState(spec projectAssistantToolSpec
6565
}
6666

6767
func projectAssistantApprovedPlanActive(plan *projectAssistantApprovedPlan) bool {
68-
return plan != nil && len(plan.Operations) > 0
68+
return plan != nil && (len(plan.Operations) > 0 || plan.AllowAllWrites)
6969
}
7070

7171
func projectAssistantApprovedPlanAllowsWrite(plan *projectAssistantApprovedPlan, toolName string, args map[string]any) bool {
@@ -78,6 +78,11 @@ func projectAssistantApprovedPlanAllowsWrite(plan *projectAssistantApprovedPlan,
7878
default:
7979
return false
8080
}
81+
// A direct user approval of a write prompt grants every write tool on any
82+
// path until the next commit, so subsequent edits do not re-prompt.
83+
if plan.AllowAllWrites {
84+
return true
85+
}
8186
if !projectAssistantApprovedPlanAllowsOperation(plan, toolName) {
8287
return false
8388
}

providers/app-studio/api/assistant_permission_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,39 @@ func TestProjectAssistantPlanApprovalAllowsScopedWritesButNotCommit(t *testing.T
108108
}
109109
}
110110

111+
func TestProjectAssistantAllowAllWritesGrantAuthorizesAnyPath(t *testing.T) {
112+
state := newProjectEinoAssistantRunState()
113+
state.ApprovePlan(projectAssistantApprovedPlan{
114+
Summary: "User approved workspace writes until the next commit.",
115+
Operations: []string{projectToolWriteFile, projectToolApplyPatch, projectToolMkdir},
116+
AllowAllWrites: true,
117+
ApprovedAt: testProjectAssistantApprovalTime(),
118+
ApprovalTool: "permission_allow_write",
119+
})
120+
121+
for _, path := range []string{"src/App.tsx", "README.md", "deploy/values.yaml"} {
122+
decision := projectAssistantPermissionForToolWithRunState(projectAssistantToolSpec{
123+
Name: projectToolWriteFile,
124+
Risk: projectAssistantToolRiskWrite,
125+
}, false, state, map[string]any{
126+
"path": path,
127+
})
128+
if decision != projectAssistantPermissionAllow {
129+
t.Fatalf("write permission for %q = %q, want %q", path, decision, projectAssistantPermissionAllow)
130+
}
131+
}
132+
133+
commitDecision := projectAssistantPermissionForToolWithRunState(projectAssistantToolSpec{
134+
Name: projectToolCommitProjectFiles,
135+
Risk: projectAssistantToolRiskCommit,
136+
}, false, state, map[string]any{
137+
"paths": []any{"src/App.tsx"},
138+
})
139+
if commitDecision != projectAssistantPermissionAsk {
140+
t.Fatalf("commit permission = %q, want %q", commitDecision, projectAssistantPermissionAsk)
141+
}
142+
}
143+
111144
func TestProjectAssistantPlanApprovalWithoutOperationsDoesNotAuthorizeWrites(t *testing.T) {
112145
state := newProjectEinoAssistantRunState()
113146
state.ApprovePlan(projectAssistantApprovedPlan{

providers/app-studio/api/repository_flow_test.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,9 +2068,11 @@ func TestResumeProjectAssistantRunPersistsAssistantTextBeforeNextPause(t *testin
20682068
firstCall := chatStreamingCall{Index: 0, ID: "call-first-write", Type: "function"}
20692069
firstCall.Function.Name = projectToolWriteFile
20702070
firstCall.Function.Arguments = `{"path":"src/App.tsx","content":"first\n"}`
2071-
secondCall := chatStreamingCall{Index: 0, ID: "call-second-write", Type: "function"}
2072-
secondCall.Function.Name = projectToolWriteFile
2073-
secondCall.Function.Arguments = `{"path":"src/Other.tsx","content":"second\n"}`
2071+
// Approving the first write grants every write until the next commit,
2072+
// so the next pause has to come from a tool that still always asks.
2073+
secondCall := chatStreamingCall{Index: 0, ID: "call-second-runtime", Type: "function"}
2074+
secondCall.Function.Name = projectToolDeployProjectRuntime
2075+
secondCall.Function.Arguments = `{"targetRef":"runtime-a","image":"ghcr.io/demo/app:latest","port":8080,"intent":"preview"}`
20742076
model := &repositoryFlowEinoChatModel{Steps: []repositoryFlowEinoModelStep{
20752077
{Message: einoschema.AssistantMessage("", projectEinoToolCallsFromStreamingForTest([]chatStreamingCall{firstCall}))},
20762078
{Message: einoschema.AssistantMessage("First change applied. ", projectEinoToolCallsFromStreamingForTest([]chatStreamingCall{secondCall}))},
@@ -2163,6 +2165,79 @@ func TestResumeProjectAssistantRunPersistsAssistantTextBeforeNextPause(t *testin
21632165
}
21642166
}
21652167

2168+
func TestResumeProjectAssistantRunAllowingWriteDoesNotRePromptLaterWrites(t *testing.T) {
2169+
messages := store.NewMemoryStore()
2170+
workspaces := workspace.NewFileStore(t.TempDir())
2171+
server := NewWithWorkspace(nil, messages, workspaces, "", false)
2172+
project := projectWithRepository("demo-repo", "demo", "github")
2173+
project.Name = "demo"
2174+
id := identity{tenantPath: "root:org-a:ws-1", orgUUID: "org-a", workspaceUUID: "ws-1"}
2175+
messageScope := projectMessageScope(id.orgUUID, id.workspaceUUID, project.Name)
2176+
workspaceScope := projectWorkspaceScope(id, project.Name)
2177+
2178+
firstCall := chatStreamingCall{Index: 0, ID: "call-first-write", Type: "function"}
2179+
firstCall.Function.Name = projectToolWriteFile
2180+
firstCall.Function.Arguments = `{"path":"src/App.tsx","content":"first\n"}`
2181+
secondCall := chatStreamingCall{Index: 0, ID: "call-second-write", Type: "function"}
2182+
secondCall.Function.Name = projectToolWriteFile
2183+
secondCall.Function.Arguments = `{"path":"src/Other.tsx","content":"second\n"}`
2184+
model := &repositoryFlowEinoChatModel{Steps: []repositoryFlowEinoModelStep{
2185+
{Message: einoschema.AssistantMessage("", projectEinoToolCallsFromStreamingForTest([]chatStreamingCall{firstCall}))},
2186+
{Message: einoschema.AssistantMessage("Applied both changes. ", projectEinoToolCallsFromStreamingForTest([]chatStreamingCall{secondCall}))},
2187+
{Message: einoschema.AssistantMessage("All done.", nil)},
2188+
}}
2189+
setProjectAssistantModelForTest(server, model)
2190+
settings := projectLLMSettings{Provider: defaultProjectLLMProvider, BaseURL: defaultProjectLLMBaseURL, Model: "test-model", APIKey: "test-key"}
2191+
client := asclient.NewFromDynamic(projectSettingsDynamicClient{secret: projectLLMSettingsSecret(settings)})
2192+
if err := appendProjectUserMessage(context.Background(), messages, messageScope, "write files"); err != nil {
2193+
t.Fatalf("appendProjectUserMessage returned error: %v", err)
2194+
}
2195+
2196+
_, err := server.generateProjectAssistantStream(
2197+
httptest.NewRequest(http.MethodPost, "/", nil),
2198+
id,
2199+
client,
2200+
project,
2201+
projectAssistantStreamCallbacks{},
2202+
)
2203+
var permissionErr *projectAssistantPermissionRequiredError
2204+
if !errors.As(err, &permissionErr) {
2205+
t.Fatalf("generateProjectAssistantStream error = %v, want permission required", err)
2206+
}
2207+
2208+
resp, err := server.resumeProjectAssistantRunWithRepositoryAndClient(
2209+
context.Background(),
2210+
httptest.NewRequest(http.MethodPost, "/", nil),
2211+
id,
2212+
client,
2213+
project,
2214+
&ProjectRepositoryView{Ref: "demo-repo", Name: "demo", Status: projectRepositoryStatusReady},
2215+
permissionErr.RunID,
2216+
projectAssistantResumeRequest{
2217+
RequestID: permissionErr.RequestID,
2218+
Decision: string(projectAssistantPermissionAllow),
2219+
},
2220+
)
2221+
if err != nil {
2222+
t.Fatalf("resumeProjectAssistantRun returned error: %v", err)
2223+
}
2224+
if resp.Status != store.AssistantRunStatusCompleted {
2225+
t.Fatalf("resume status = %q, want %q (second write should auto-approve)", resp.Status, store.AssistantRunStatusCompleted)
2226+
}
2227+
2228+
files, err := workspaces.ListFiles(context.Background(), workspaceScope, workspace.ListOptions{})
2229+
if err != nil {
2230+
t.Fatalf("ListFiles returned error: %v", err)
2231+
}
2232+
written := map[string]bool{}
2233+
for _, f := range files.Files {
2234+
written[f.Path] = true
2235+
}
2236+
if !written["src/App.tsx"] || !written["src/Other.tsx"] {
2237+
t.Fatalf("written files = %v, want both src/App.tsx and src/Other.tsx", written)
2238+
}
2239+
}
2240+
21662241
func TestResumeProjectAssistantRunRejectsStaleRepositoryBinding(t *testing.T) {
21672242
var sawCommit bool
21682243
mcp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)