Skip to content

Commit 4d06c20

Browse files
committed
New meta (Release v0.8.1)
1 parent 6d0dd18 commit 4d06c20

File tree

13 files changed

+1527
-12
lines changed

13 files changed

+1527
-12
lines changed

internal/execution/controller.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ const (
5757
ActionTaskUpdate Action = "task_update"
5858
ActionTaskComplete Action = "task_complete"
5959
ActionModeDeclaration Action = "memory_set_mode"
60+
ActionArtifactCreate Action = "artifact_create"
61+
ActionArtifactRead Action = "artifact_read"
62+
ActionArtifactList Action = "artifact_list"
6063
)
6164

6265
// Controller tracks the current execution mode, escalation state, and tinyTasks mutations.

internal/intent/definition.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const (
1010
CategoryTask Category = "task"
1111
CategoryDiagnostics Category = "diagnostics"
1212
CategoryMode Category = "mode_declaration"
13+
CategoryArtifact Category = "artifact_write"
1314
)
1415

1516
// Definition describes the declarative intent metadata attached to a tool.
@@ -159,4 +160,27 @@ var ToolDefinitions = map[string]Definition{
159160
SideEffects: []string{"mutates tinyTasks.md"},
160161
RequiresRecall: true,
161162
},
163+
"artifact_create": {
164+
Name: "artifact_create",
165+
Category: CategoryArtifact,
166+
RequiredMode: execution.ModeGuarded,
167+
RequiresRecall: false,
168+
Description: "Create or update a project artifact (file). The server validates the path and performs the write; agents never touch the filesystem directly.",
169+
Scope: "workspace",
170+
SideEffects: []string{"creates or updates files in workspace"},
171+
},
172+
"artifact_read": {
173+
Name: "artifact_read",
174+
Category: CategoryArtifact,
175+
RequiredMode: execution.ModePassive,
176+
Description: "Read the contents of a workspace artifact. Observation only; no state is mutated.",
177+
Scope: "workspace",
178+
},
179+
"artifact_list": {
180+
Name: "artifact_list",
181+
Category: CategoryArtifact,
182+
RequiredMode: execution.ModePassive,
183+
Description: "List files in the workspace with optional glob filtering. Observation only; internal directories and TaskManager-owned files are always excluded.",
184+
Scope: "workspace",
185+
},
162186
}

internal/server/artifact.go

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package server
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
)
11+
12+
// ArtifactResult is the confirmation payload returned to the caller after a successful write.
13+
type ArtifactResult struct {
14+
Path string `json:"path"`
15+
BytesWritten int `json:"bytes_written"`
16+
Created bool `json:"created"`
17+
SHA256 string `json:"sha256"`
18+
LinkTaskID string `json:"link_task_id,omitempty"`
19+
}
20+
21+
// ValidateArtifactPath resolves and constrains a relative path to the workspace root.
22+
// It rejects absolute paths, directory-traversal sequences, and any resolved path that
23+
// falls outside the workspace. The returned string is the fully-resolved absolute path
24+
// that is safe to write.
25+
func ValidateArtifactPath(workspaceRoot, relPath string) (string, error) {
26+
if strings.TrimSpace(relPath) == "" {
27+
return "", fmt.Errorf("artifact path must not be empty")
28+
}
29+
30+
if filepath.IsAbs(relPath) {
31+
return "", fmt.Errorf("artifact path must be relative: %s", relPath)
32+
}
33+
34+
cleaned := filepath.Clean(relPath)
35+
36+
// Reject the workspace root itself and any leading traversal.
37+
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
38+
return "", fmt.Errorf("artifact path escapes workspace: %s", relPath)
39+
}
40+
41+
// Resolve the workspace root (handles symlinks at root level).
42+
absRoot, err := filepath.EvalSymlinks(workspaceRoot)
43+
if err != nil {
44+
absRoot, err = filepath.Abs(workspaceRoot)
45+
if err != nil {
46+
return "", fmt.Errorf("failed to resolve workspace root: %w", err)
47+
}
48+
}
49+
50+
target := filepath.Join(absRoot, cleaned)
51+
52+
// Defense-in-depth: use filepath.Rel to verify containment after join.
53+
rel, err := filepath.Rel(absRoot, target)
54+
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
55+
return "", fmt.Errorf("artifact path escapes workspace: %s", relPath)
56+
}
57+
58+
return target, nil
59+
}
60+
61+
// WriteArtifact performs the server-owned file write after path validation.
62+
// The write is atomic: content is written to a temporary file in the target
63+
// directory and then renamed into place so a crash mid-write cannot leave a
64+
// partial file. Failure is explicit and fail-closed.
65+
func WriteArtifact(workspaceRoot, relPath, content string, overwrite bool, linkTaskID string) (ArtifactResult, error) {
66+
absPath, err := ValidateArtifactPath(workspaceRoot, relPath)
67+
if err != nil {
68+
return ArtifactResult{}, err
69+
}
70+
71+
created := true
72+
if _, statErr := os.Stat(absPath); statErr == nil {
73+
// File already exists.
74+
if !overwrite {
75+
return ArtifactResult{}, fmt.Errorf("artifact already exists and overwrite is false: %s", relPath)
76+
}
77+
created = false
78+
}
79+
80+
// Ensure parent directories exist.
81+
dir := filepath.Dir(absPath)
82+
if mkErr := os.MkdirAll(dir, 0755); mkErr != nil {
83+
return ArtifactResult{}, fmt.Errorf("failed to create directory %s: %w", dir, mkErr)
84+
}
85+
86+
// Atomic write: temp file in the same directory, then rename.
87+
tmp, mkErr := os.CreateTemp(dir, ".tinymem-artifact-*")
88+
if mkErr != nil {
89+
return ArtifactResult{}, fmt.Errorf("failed to create temp file: %w", mkErr)
90+
}
91+
tmpName := tmp.Name()
92+
93+
data := []byte(content)
94+
if _, writeErr := tmp.Write(data); writeErr != nil {
95+
tmp.Close()
96+
os.Remove(tmpName)
97+
return ArtifactResult{}, fmt.Errorf("failed to write artifact: %w", writeErr)
98+
}
99+
if closeErr := tmp.Close(); closeErr != nil {
100+
os.Remove(tmpName)
101+
return ArtifactResult{}, fmt.Errorf("failed to close temp file: %w", closeErr)
102+
}
103+
104+
if renameErr := os.Rename(tmpName, absPath); renameErr != nil {
105+
os.Remove(tmpName)
106+
return ArtifactResult{}, fmt.Errorf("failed to finalize artifact write: %w", renameErr)
107+
}
108+
109+
h := sha256.New()
110+
h.Write(data)
111+
112+
return ArtifactResult{
113+
Path: relPath,
114+
BytesWritten: len(data),
115+
Created: created,
116+
SHA256: hex.EncodeToString(h.Sum(nil)),
117+
LinkTaskID: linkTaskID,
118+
}, nil
119+
}
120+
121+
// maxArtifactReadBytes is the ceiling on file size that artifact_read will
122+
// return. Files above this limit are likely binary or generated and should
123+
// not be streamed into an agent context.
124+
const maxArtifactReadBytes = 1 << 20 // 1 MiB
125+
126+
// excludedDirs are directories pruned from workspace listings.
127+
// .tinyMem holds runtime state; .git is version-control noise.
128+
var excludedDirs = map[string]bool{
129+
".tinyMem": true,
130+
".git": true,
131+
"node_modules": true,
132+
}
133+
134+
// isTaskManagerOwned returns true when the cleaned relative path refers to the
135+
// root-level tinyTasks.md — the single file owned exclusively by TaskManager
136+
// under Phase 1 invariants. Nested files with the same name are not excluded.
137+
func isTaskManagerOwned(cleanedRel string) bool {
138+
return cleanedRel == "tinyTasks.md"
139+
}
140+
141+
// ArtifactReadResult is the payload returned by a successful artifact read.
142+
type ArtifactReadResult struct {
143+
Path string `json:"path"`
144+
Content string `json:"content"`
145+
Size int64 `json:"size"`
146+
SHA256 string `json:"sha256"`
147+
}
148+
149+
// ReadArtifact reads a workspace artifact after path validation. It enforces
150+
// a size ceiling and explicitly blocks TaskManager-owned files.
151+
func ReadArtifact(workspaceRoot, relPath string) (ArtifactReadResult, error) {
152+
absPath, err := ValidateArtifactPath(workspaceRoot, relPath)
153+
if err != nil {
154+
return ArtifactReadResult{}, err
155+
}
156+
157+
if isTaskManagerOwned(filepath.Clean(relPath)) {
158+
return ArtifactReadResult{}, fmt.Errorf("tinyTasks.md is owned by TaskManager; use the task_* tools")
159+
}
160+
161+
info, err := os.Stat(absPath)
162+
if err != nil {
163+
return ArtifactReadResult{}, fmt.Errorf("artifact not found: %s", relPath)
164+
}
165+
if info.IsDir() {
166+
return ArtifactReadResult{}, fmt.Errorf("%s is a directory; use artifact_list to enumerate", relPath)
167+
}
168+
if info.Size() > maxArtifactReadBytes {
169+
return ArtifactReadResult{}, fmt.Errorf("artifact %s exceeds the read size limit (%d bytes; max %d)", relPath, info.Size(), maxArtifactReadBytes)
170+
}
171+
172+
data, err := os.ReadFile(absPath)
173+
if err != nil {
174+
return ArtifactReadResult{}, fmt.Errorf("failed to read artifact: %w", err)
175+
}
176+
177+
h := sha256.New()
178+
h.Write(data)
179+
180+
return ArtifactReadResult{
181+
Path: relPath,
182+
Content: string(data),
183+
Size: info.Size(),
184+
SHA256: hex.EncodeToString(h.Sum(nil)),
185+
}, nil
186+
}
187+
188+
// ArtifactEntry describes a single file discovered during a workspace listing.
189+
type ArtifactEntry struct {
190+
Path string `json:"path"`
191+
Size int64 `json:"size"`
192+
}
193+
194+
// ListArtifacts walks the workspace and returns visible files, optionally
195+
// filtered by a glob pattern. Internal directories (.tinyMem, .git,
196+
// node_modules) and the root-level tinyTasks.md are always excluded.
197+
func ListArtifacts(workspaceRoot, pattern string) ([]ArtifactEntry, error) {
198+
absRoot, err := filepath.EvalSymlinks(workspaceRoot)
199+
if err != nil {
200+
absRoot, err = filepath.Abs(workspaceRoot)
201+
if err != nil {
202+
return nil, fmt.Errorf("failed to resolve workspace root: %w", err)
203+
}
204+
}
205+
206+
var entries []ArtifactEntry
207+
208+
err = filepath.Walk(absRoot, func(path string, info os.FileInfo, walkErr error) error {
209+
if walkErr != nil {
210+
return walkErr
211+
}
212+
213+
rel, _ := filepath.Rel(absRoot, path)
214+
if rel == "." {
215+
return nil
216+
}
217+
218+
if info.IsDir() {
219+
if excludedDirs[info.Name()] {
220+
return filepath.SkipDir
221+
}
222+
return nil
223+
}
224+
225+
if isTaskManagerOwned(rel) {
226+
return nil
227+
}
228+
229+
// Apply glob filter when one was provided.
230+
if pattern != "" {
231+
matched, matchErr := matchArtifactPattern(pattern, rel)
232+
if matchErr != nil {
233+
return matchErr
234+
}
235+
if !matched {
236+
return nil
237+
}
238+
}
239+
240+
entries = append(entries, ArtifactEntry{
241+
Path: filepath.ToSlash(rel),
242+
Size: info.Size(),
243+
})
244+
return nil
245+
})
246+
if err != nil {
247+
return nil, fmt.Errorf("failed to list workspace: %w", err)
248+
}
249+
250+
return entries, nil
251+
}
252+
253+
// matchArtifactPattern applies a glob pattern to a relative path. When the
254+
// pattern contains no path separator it is matched against the base filename
255+
// only, so "*.html" will match files in any subdirectory. Patterns that
256+
// contain a separator (e.g. "src/*.go") are matched against the full relative
257+
// path.
258+
func matchArtifactPattern(pattern, relPath string) (bool, error) {
259+
if strings.ContainsRune(pattern, '/') || strings.ContainsRune(pattern, filepath.Separator) {
260+
return filepath.Match(pattern, relPath)
261+
}
262+
return filepath.Match(pattern, filepath.Base(relPath))
263+
}

internal/server/intent_codes.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@ const (
88
enforcementCodeFactEvidence = "FACT_EVIDENCE_REQUIRED"
99
enforcementCodeClaimViolation = "CLAIM_WITHOUT_ENFORCEMENT"
1010
enforcementCodeModeUpdated = "MODE_UPDATED"
11-
enforcementCodeRecallRequired = "RECALL_REQUIRED"
11+
enforcementCodeRecallRequired = "RECALL_REQUIRED"
12+
enforcementCodeArtifactPathEscape = "ARTIFACT_PATH_ESCAPE"
1213

1314
// Exposed aliases for enforcement reporting.
14-
EnforcementCodeModeCompliance = enforcementCodeModeCompliance
15-
EnforcementCodeModeTooLow = enforcementCodeModeTooLow
16-
EnforcementCodeModeNotSet = enforcementCodeModeNotSet
17-
EnforcementCodeStrictRequired = enforcementCodeStrictRequired
18-
EnforcementCodeFactEvidence = enforcementCodeFactEvidence
19-
EnforcementCodeClaimViolation = enforcementCodeClaimViolation
20-
EnforcementCodeModeUpdated = enforcementCodeModeUpdated
21-
EnforcementCodeRecallRequired = enforcementCodeRecallRequired
15+
EnforcementCodeModeCompliance = enforcementCodeModeCompliance
16+
EnforcementCodeModeTooLow = enforcementCodeModeTooLow
17+
EnforcementCodeModeNotSet = enforcementCodeModeNotSet
18+
EnforcementCodeStrictRequired = enforcementCodeStrictRequired
19+
EnforcementCodeFactEvidence = enforcementCodeFactEvidence
20+
EnforcementCodeClaimViolation = enforcementCodeClaimViolation
21+
EnforcementCodeModeUpdated = enforcementCodeModeUpdated
22+
EnforcementCodeRecallRequired = enforcementCodeRecallRequired
23+
EnforcementCodeArtifactPathEscape = enforcementCodeArtifactPathEscape
2224
)

0 commit comments

Comments
 (0)