Skip to content

Commit c44ae45

Browse files
nucleuskitxusihong
andauthored
feat(cli): add apply, execute, and repair commands (#13)
## Summary Adds three AI-safe change automation subcommands to `nucleus`: `apply` validates and applies executable plan edits with edit-surface enforcement and rollback evidence, `execute` runs allowlisted plan commands with timeout and redaction evidence, and `repair` handles bounded generated-artifact repair plus hash-checked patch candidates followed by verification. The command packages now match the existing CLI structure (`command`, `constants`, `run`, `output`), root wiring delegates to each package, and repair uses full verify evidence after automatic fixes. ## Type - [ ] Bug fix - [x] Feature - [ ] Documentation - [x] Refactor - [ ] Build or CI ## Contract / Manifest Impact - [x] No public contract or manifest impact - [ ] OpenAPI changed - [ ] Protobuf changed - [ ] `api/errors.yaml` changed - [ ] `nucleus.yaml` changed - [ ] Generated files refreshed Note: this PR does add CLI subcommands, flags, and evidence outputs for apply/execute/repair. ## Verification ```text rtk go test ./... -> passed: 55 tests in 11 packages rtk go test ./... -race -count=1 -> passed: 55 tests in 11 packages rtk go run ./cmd/nucleus validate --dir example/hello-http -> passed: 0 errors, 0 warnings rtk go run ./cmd/nucleus lint --dir example/hello-http -> passed: findings 0 rtk env GOWORK=off go run ./cmd/nucleus verify --dir example/hello-http --json -> passed: ok=true, 7/7 steps rtk go run ./cmd/nucleus lint --dir . -> failed: root directory has no nucleus.yaml in the current repo layout rtk go run ./cmd/nucleus verify --dir example/hello-http --json -> failed at go list under the default go.work environment; the same verify command passed with GOWORK=off ``` ## Risk The new automation commands touch security-sensitive paths: `apply` can write files, `execute` can run caller-allowlisted commands, and `repair` can regenerate or patch files. The implementation mitigates this with edit-surface checks, symlink/path escape rejection, allowlisted command names, timeouts, log redaction, bounded patch matching, rollback evidence, and post-repair verification. Repair remains intentionally conservative; unsupported or ambiguous fixes return `needs_manual_action`. Rollback evidence may include original file contents, so generated evidence should be treated as sensitive. CLI reference documentation for these new subcommands may need a follow-up if maintainers want user-facing command docs beyond tests and help output. --------- Co-authored-by: xusihong <xusihong@bestfulfill.com>
1 parent 329d6b8 commit c44ae45

24 files changed

Lines changed: 2532 additions & 2 deletions
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
package apply
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/nucleuskit/contract/inspect"
11+
)
12+
13+
func BuildEvidence(dir string, planPath string) (map[string]any, error) {
14+
plan, err := readJSONObject(planPath)
15+
if err != nil {
16+
return nil, err
17+
}
18+
description, err := inspect.Describe(dir)
19+
if err != nil {
20+
return nil, err
21+
}
22+
edits := anyMapSlice(plan["edits"])
23+
steps := make([]map[string]any, 0, len(edits))
24+
pass := true
25+
for index, edit := range edits {
26+
path, _ := edit["path"].(string)
27+
surface := classifyEditSurface(path, description.EditSurfaces)
28+
allowed := surface == "allowed"
29+
if !allowed {
30+
pass = false
31+
}
32+
steps = append(steps, map[string]any{
33+
"id": fmt.Sprintf("apply-dry-run-%d", index+1),
34+
"kind": "edit_surface_check",
35+
"path": path,
36+
"surface": surface,
37+
"pass": allowed,
38+
"exit_code": boolExitCode(allowed),
39+
"needs_write": false,
40+
})
41+
}
42+
return map[string]any{
43+
"schema_version": "evidence.v1",
44+
"kind": "nucleus.apply_evidence",
45+
"mode": "dry-run",
46+
"pass": pass,
47+
"steps": steps,
48+
"diffs": []string{},
49+
"rollback_points": []map[string]any{
50+
{"id": "dry-run", "strategy": "no writes performed", "available": true},
51+
},
52+
}, nil
53+
}
54+
55+
func Apply(dir string, planPath string) (map[string]any, error) {
56+
plan, err := readJSONObject(planPath)
57+
if err != nil {
58+
return nil, err
59+
}
60+
description, err := inspect.Describe(dir)
61+
if err != nil {
62+
return nil, err
63+
}
64+
edits := anyMapSlice(plan["edits"])
65+
commands := anyMapSlice(plan["commands"])
66+
steps := make([]map[string]any, 0, len(edits)+len(commands))
67+
pass := true
68+
for index, edit := range edits {
69+
path, _ := edit["path"].(string)
70+
surface := classifyEditSurface(path, description.EditSurfaces)
71+
allowed := surface == "allowed"
72+
if _, ok := edit["content"].(string); !ok {
73+
allowed = false
74+
}
75+
if !allowed {
76+
pass = false
77+
}
78+
steps = append(steps, map[string]any{
79+
"id": fmt.Sprintf("apply-check-%d", index+1),
80+
"kind": "edit_surface_check",
81+
"path": path,
82+
"surface": surface,
83+
"pass": allowed,
84+
"exit_code": boolExitCode(allowed),
85+
"needs_write": true,
86+
})
87+
}
88+
if !pass {
89+
steps = appendSkippedCommands(steps, commands)
90+
return applyEvidence("apply", false, steps, []map[string]any{}), nil
91+
}
92+
93+
rollbackPoints := make([]map[string]any, 0, len(edits))
94+
for index, edit := range edits {
95+
path, _ := edit["path"].(string)
96+
fullPath, err := resolveEditPath(dir, path)
97+
if err != nil {
98+
steps = append(steps, map[string]any{
99+
"id": fmt.Sprintf("apply-write-%d", index+1),
100+
"kind": "file_write",
101+
"path": path,
102+
"pass": false,
103+
"exit_code": 1,
104+
"error": err.Error(),
105+
})
106+
steps = appendSkippedCommands(steps, commands)
107+
return applyEvidence("apply", false, steps, rollbackPoints), nil
108+
}
109+
if err := rejectSymlinkPath(dir, fullPath, path); err != nil {
110+
steps = append(steps, map[string]any{
111+
"id": fmt.Sprintf("apply-write-%d", index+1),
112+
"kind": "file_write",
113+
"path": path,
114+
"pass": false,
115+
"exit_code": 1,
116+
"error": err.Error(),
117+
})
118+
steps = appendSkippedCommands(steps, commands)
119+
return applyEvidence("apply", false, steps, rollbackPoints), nil
120+
}
121+
rollbackPoint, err := buildRollbackPoint(index, path, fullPath)
122+
if err != nil {
123+
steps = append(steps, map[string]any{
124+
"id": fmt.Sprintf("apply-write-%d", index+1),
125+
"kind": "file_write",
126+
"path": path,
127+
"pass": false,
128+
"exit_code": 1,
129+
"error": err.Error(),
130+
})
131+
steps = appendSkippedCommands(steps, commands)
132+
return applyEvidence("apply", false, steps, rollbackPoints), nil
133+
}
134+
rollbackPoints = append(rollbackPoints, rollbackPoint)
135+
content, _ := edit["content"].(string)
136+
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
137+
steps = append(steps, map[string]any{
138+
"id": fmt.Sprintf("apply-write-%d", index+1),
139+
"kind": "file_write",
140+
"path": path,
141+
"pass": false,
142+
"exit_code": 1,
143+
"error": err.Error(),
144+
})
145+
steps = appendSkippedCommands(steps, commands)
146+
return applyEvidence("apply", false, steps, rollbackPoints), nil
147+
}
148+
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
149+
steps = append(steps, map[string]any{
150+
"id": fmt.Sprintf("apply-write-%d", index+1),
151+
"kind": "file_write",
152+
"path": path,
153+
"pass": false,
154+
"exit_code": 1,
155+
"error": err.Error(),
156+
})
157+
steps = appendSkippedCommands(steps, commands)
158+
return applyEvidence("apply", false, steps, rollbackPoints), nil
159+
}
160+
steps = append(steps, map[string]any{
161+
"id": fmt.Sprintf("apply-write-%d", index+1),
162+
"kind": "file_write",
163+
"path": path,
164+
"pass": true,
165+
"exit_code": 0,
166+
"bytes": len(content),
167+
})
168+
}
169+
steps = appendSkippedCommands(steps, commands)
170+
return applyEvidence("apply", true, steps, rollbackPoints), nil
171+
}
172+
173+
func readJSONObject(path string) (map[string]any, error) {
174+
data, err := os.ReadFile(path)
175+
if err != nil {
176+
return nil, err
177+
}
178+
var value map[string]any
179+
if err := json.Unmarshal(data, &value); err != nil {
180+
return nil, err
181+
}
182+
return value, nil
183+
}
184+
185+
func anyMapSlice(value any) []map[string]any {
186+
items, ok := value.([]any)
187+
if !ok {
188+
return nil
189+
}
190+
result := make([]map[string]any, 0, len(items))
191+
for _, item := range items {
192+
if mapped, ok := item.(map[string]any); ok {
193+
result = append(result, mapped)
194+
}
195+
}
196+
return result
197+
}
198+
199+
func appendSkippedCommands(steps []map[string]any, commands []map[string]any) []map[string]any {
200+
for index, command := range commands {
201+
commandText, _ := command["command"].(string)
202+
steps = append(steps, map[string]any{
203+
"id": fmt.Sprintf("command-skipped-%d", index+1),
204+
"kind": "command_skipped",
205+
"command": commandText,
206+
"pass": true,
207+
"exit_code": 0,
208+
"reason": "apply does not execute shell commands",
209+
})
210+
}
211+
return steps
212+
}
213+
214+
func applyEvidence(mode string, pass bool, steps []map[string]any, rollbackPoints []map[string]any) map[string]any {
215+
return map[string]any{
216+
"schema_version": "evidence.v1",
217+
"kind": "nucleus.apply_evidence",
218+
"mode": mode,
219+
"pass": pass,
220+
"steps": steps,
221+
"diffs": []string{},
222+
"rollback_points": rollbackPoints,
223+
}
224+
}
225+
226+
func resolveEditPath(dir string, path string) (string, error) {
227+
if filepath.IsAbs(path) {
228+
return "", fmt.Errorf("absolute edit path is not allowed: %s", path)
229+
}
230+
root, err := filepath.Abs(dir)
231+
if err != nil {
232+
return "", err
233+
}
234+
fullPath := filepath.Join(root, filepath.FromSlash(path))
235+
rel, err := filepath.Rel(root, fullPath)
236+
if err != nil {
237+
return "", err
238+
}
239+
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
240+
return "", fmt.Errorf("edit path escapes service root: %s", path)
241+
}
242+
return fullPath, nil
243+
}
244+
245+
func rejectSymlinkPath(dir string, fullPath string, displayPath string) error {
246+
root, err := filepath.Abs(dir)
247+
if err != nil {
248+
return err
249+
}
250+
rel, err := filepath.Rel(root, fullPath)
251+
if err != nil {
252+
return err
253+
}
254+
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
255+
return fmt.Errorf("edit path escapes service root: %s", displayPath)
256+
}
257+
clean := filepath.Clean(rel)
258+
current := root
259+
for _, part := range strings.Split(clean, string(filepath.Separator)) {
260+
if part == "." || part == "" {
261+
continue
262+
}
263+
current = filepath.Join(current, part)
264+
info, err := os.Lstat(current)
265+
if err == nil && info.Mode()&os.ModeSymlink != 0 {
266+
return fmt.Errorf("edit path contains symlink: %s", displayPath)
267+
}
268+
if os.IsNotExist(err) {
269+
return nil
270+
}
271+
if err != nil {
272+
return err
273+
}
274+
}
275+
return nil
276+
}
277+
278+
func buildRollbackPoint(index int, path string, fullPath string) (map[string]any, error) {
279+
data, err := os.ReadFile(fullPath)
280+
if err == nil {
281+
return map[string]any{
282+
"id": fmt.Sprintf("rollback-%d", index+1),
283+
"path": path,
284+
"strategy": "restore original content",
285+
"available": true,
286+
"existed": true,
287+
"original_content": string(data),
288+
}, nil
289+
}
290+
if os.IsNotExist(err) {
291+
return map[string]any{
292+
"id": fmt.Sprintf("rollback-%d", index+1),
293+
"path": path,
294+
"strategy": "remove created file",
295+
"available": true,
296+
"existed": false,
297+
}, nil
298+
}
299+
return nil, err
300+
}
301+
302+
func classifyEditSurface(path string, surfaces inspect.EditSurfaces) string {
303+
switch {
304+
case matchesAnySurface(path, surfaces.Forbidden):
305+
return "forbidden"
306+
case matchesAnySurface(path, surfaces.Readonly):
307+
return "readonly"
308+
case matchesAnySurface(path, surfaces.Allowed):
309+
return "allowed"
310+
default:
311+
return "manual"
312+
}
313+
}
314+
315+
func matchesAnySurface(path string, patterns []string) bool {
316+
for _, pattern := range patterns {
317+
if surfaceMatch(path, pattern) {
318+
return true
319+
}
320+
}
321+
return false
322+
}
323+
324+
func surfaceMatch(path string, pattern string) bool {
325+
pattern = filepath.ToSlash(strings.TrimPrefix(pattern, "./"))
326+
path = filepath.ToSlash(strings.TrimPrefix(path, "./"))
327+
if pattern == path || pattern == "**" {
328+
return true
329+
}
330+
if ok, _ := filepath.Match(pattern, path); ok {
331+
return true
332+
}
333+
if strings.HasSuffix(pattern, "/**") {
334+
return strings.HasPrefix(path, strings.TrimSuffix(pattern, "/**")+"/") || path == strings.TrimSuffix(pattern, "/**")
335+
}
336+
if strings.HasSuffix(pattern, "/*") {
337+
prefix := strings.TrimSuffix(pattern, "/*") + "/"
338+
return strings.HasPrefix(path, prefix) && !strings.Contains(strings.TrimPrefix(path, prefix), "/")
339+
}
340+
return false
341+
}
342+
343+
func boolExitCode(pass bool) int {
344+
if pass {
345+
return 0
346+
}
347+
return 1
348+
}

0 commit comments

Comments
 (0)