Skip to content

Commit acad9f3

Browse files
michaelmcneesclaude
andcommitted
fix: v0.2.0 release hardening — security, code quality, docs accuracy
Security: - Docker: restrict network mode to bridge/none (prevent host network escape) - Docker: drop all capabilities, set no-new-privileges, PID limit 512 - Docker: block dangerous mount targets (/proc, /sys, /dev, docker.sock) - CEL: bound split() to 10k parts, flatten() to 100k elements - CEL: cap jsonDecode input to 1MB - CEL: bound program cache to 10k entries (prevent unbounded growth) - Engine: use os.Lstat for artifact stat (symlink defense-in-depth) Code quality: - Docker: replace log.Printf with structured slog throughout - Docker: document limitWriter drain semantics (io.Writer contract) - Engine: replace defer-in-retry-loop with explicit cleanup - Engine: fix %v → %w for proper error chain wrapping - Artifact store: wrap rows.Err() with context, add ErrNotFound sentinel - Artifact reaper: handle os.ErrNotExist (force-delete orphaned metadata) - Artifact reaper: wire into server.go with periodic sweep goroutine Documentation: - Fix broken example YAMLs (inputs format, field names, CEL variables) - Add tmp block to configuration full example - Add cross-links between artifacts and docker-workflows docs - Document registry_credential as step-level field - Document container security hardening in connector reference - Clarify toString() vs string() in expressions reference - Update Slack channel param to document both ID and name formats Testing: - Add CI-aware fatal guards to all 8 testcontainers helpers - Add TestDockerRun_RejectsHostNetwork - Add -race flag to Makefile test target Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 162d57e commit acad9f3

26 files changed

Lines changed: 232 additions & 61 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ build:
1111
go build -ldflags "$(LDFLAGS)" -o mantle ./cmd/mantle
1212

1313
test:
14-
go test ./...
14+
go test -race ./...
1515

1616
lint:
1717
golangci-lint run

examples/ai-tool-use.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: research-assistant
22
description: AI agent that searches the web and summarizes findings
33

44
inputs:
5-
- name: topic
5+
topic:
66
type: string
77
description: Research topic
88

examples/db-backup.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ inputs:
99
database_url:
1010
type: string
1111
description: Postgres connection URL to back up
12-
default: "{{ env.MANTLE_DATABASE_URL }}"
12+
default: "{{ env.DATABASE_URL }}"
1313
s3_bucket:
1414
type: string
1515
description: S3 bucket for backup storage
@@ -75,7 +75,7 @@ steps:
7575
Today's date is {{ env.MANTLE_ENV_DATE }}.
7676
7777
Files:
78-
{{ steps.list-backups.output.keys }}
78+
{{ steps.list-backups.output.objects }}
7979
8080
Return an empty array [] if nothing should be deleted.
8181
output_schema:

examples/docker-volume-backup.yaml

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,14 @@ steps:
3232
timeout: "5m"
3333
params:
3434
bucket: my-backups
35-
key: "volumes/my-app-data/{{ date }}/backup.tar.gz"
35+
key: "volumes/my-app-data/backup.tar.gz"
3636
content: "{{ artifacts['backup-archive'].url }}"
3737
content_type: "application/gzip"
3838

3939
- name: notify-success
4040
action: slack/send
4141
credential: slack-token
42-
if: "steps['upload-to-s3'].error == null && steps['upload-to-s3'].output.size > 0"
42+
depends_on: [upload-to-s3]
4343
params:
4444
channel: "#ops-alerts"
4545
text: "Volume backup completed — {{ artifacts['backup-archive'].size }} bytes uploaded to s3://my-backups/volumes/my-app-data/"
46-
47-
# NOTE: notify-failure requires continue_on_error support (see issue #29).
48-
# Without it, the engine halts on the first failed step and this step
49-
# will not execute. This is included to show the intended pattern.
50-
- name: notify-failure
51-
action: slack/send
52-
credential: slack-token
53-
if: "steps['upload-to-s3'].error != null || steps['backup-volume'].error != null"
54-
params:
55-
channel: "#ops-alerts"
56-
text: "Volume backup FAILED"

examples/s3-backup.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ steps:
3232
bucket: "mantle-backups"
3333
key: "daily-export/posts-{{ steps['fetch-data'].output.json[0].id }}.json"
3434
content_type: "application/json"
35-
body: "{{ steps['fetch-data'].output.body }}"
35+
content: "{{ steps['fetch-data'].output.body }}"

examples/webhook-processor.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ steps:
2323
Content-Type: "application/json"
2424
body:
2525
title: "Webhook payload received"
26-
body: "{{ inputs.trigger.payload }}"
27-
source_path: "{{ inputs.trigger.path }}"
26+
body: "{{ trigger.payload }}"
27+
source_path: "{{ trigger.path }}"
2828

2929
- name: log-success
3030
action: http/request

internal/artifact/reaper.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package artifact
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"log/slog"
8+
"os"
79
"time"
810
)
911

@@ -40,9 +42,14 @@ func (r *Reaper) Sweep(ctx context.Context) (int, error) {
4042
for _, a := range expired {
4143
// Delete file from tmp storage.
4244
if delErr := r.TmpStorage.Delete(ctx, a.URL); delErr != nil {
43-
logger.Error("failed to delete artifact file",
44-
"artifact", a.Name, "url", a.URL, "error", delErr)
45-
continue // skip metadata deletion if file delete failed
45+
// If the blob is already gone, still clean up the metadata.
46+
if !errors.Is(delErr, os.ErrNotExist) {
47+
logger.Error("failed to delete artifact file",
48+
"artifact", a.Name, "url", a.URL, "error", delErr)
49+
continue // skip metadata deletion if file delete genuinely failed
50+
}
51+
logger.Warn("artifact file already missing, cleaning metadata",
52+
"artifact", a.Name, "url", a.URL)
4653
}
4754

4855
// Delete metadata from database.

internal/artifact/store.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ package artifact
33
import (
44
"context"
55
"database/sql"
6+
"errors"
67
"fmt"
78
"time"
89
)
910

11+
// ErrNotFound is returned when an artifact does not exist.
12+
var ErrNotFound = errors.New("artifact not found")
13+
1014
// Artifact represents metadata for a persisted artifact.
1115
type Artifact struct {
1216
ID string
@@ -45,7 +49,7 @@ func (s *Store) GetByName(ctx context.Context, executionID, name string) (*Artif
4549
WHERE execution_id = $1 AND name = $2
4650
`, executionID, name).Scan(&a.ID, &a.ExecutionID, &a.StepName, &a.Name, &a.URL, &a.Size, &a.CreatedAt)
4751
if err == sql.ErrNoRows {
48-
return nil, fmt.Errorf("artifact %q not found for execution %s", name, executionID)
52+
return nil, fmt.Errorf("artifact %q not found for execution %s: %w", name, executionID, ErrNotFound)
4953
}
5054
if err != nil {
5155
return nil, fmt.Errorf("artifact get: %w", err)
@@ -74,7 +78,10 @@ func (s *Store) ListByExecution(ctx context.Context, executionID string) ([]Arti
7478
}
7579
artifacts = append(artifacts, a)
7680
}
77-
return artifacts, rows.Err()
81+
if err := rows.Err(); err != nil {
82+
return nil, fmt.Errorf("artifact list: %w", err)
83+
}
84+
return artifacts, nil
7885
}
7986

8087
// DeleteByExecution removes all artifact metadata for an execution.
@@ -120,5 +127,8 @@ func (s *Store) ListExpired(ctx context.Context, olderThan time.Duration) ([]Art
120127
}
121128
artifacts = append(artifacts, a)
122129
}
123-
return artifacts, rows.Err()
130+
if err := rows.Err(); err != nil {
131+
return nil, fmt.Errorf("artifact list expired: %w", err)
132+
}
133+
return artifacts, nil
124134
}

internal/auth/auth_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"database/sql"
66
"net/http"
77
"net/http/httptest"
8+
"os"
89
"testing"
910
"time"
1011

@@ -32,6 +33,9 @@ func setupTestStore(t *testing.T) *Store {
3233
),
3334
)
3435
if err != nil {
36+
if os.Getenv("CI") != "" {
37+
t.Fatalf("Could not start Postgres container (CI): %v", err)
38+
}
3539
t.Skipf("Could not start Postgres container: %v", err)
3640
}
3741
t.Cleanup(func() { pgContainer.Terminate(ctx) })

internal/budget/store_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package budget_test
33
import (
44
"context"
55
"database/sql"
6+
"os"
67
"testing"
78
"time"
89

@@ -32,6 +33,9 @@ func setupTestDB(t *testing.T) *sql.DB {
3233
),
3334
)
3435
if err != nil {
36+
if os.Getenv("CI") != "" {
37+
t.Fatalf("Could not start Postgres container (CI): %v", err)
38+
}
3539
t.Skipf("Could not start Postgres container: %v", err)
3640
}
3741
t.Cleanup(func() {

0 commit comments

Comments
 (0)