Skip to content

Commit 594c72f

Browse files
michaelmcneesclaude
andcommitted
merge: resolve conflicts with main (v0.4.0 + env-map)
Keep all v0.4.0 features (concurrency controls, hooks, storage rename, env map, queued status) alongside workflow composition additions (MaxWorkflowDepth, RegisterWorkflowConnector, recursive cancellation). Renumber workflow_composition migration from 016 to 017 to avoid collision with 016_safety_net. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2 parents 50623c8 + e73029e commit 594c72f

42 files changed

Lines changed: 3613 additions & 191 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/claude.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Claude Code
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
pull_request_review_comment:
7+
types: [created]
8+
issues:
9+
types: [opened, assigned]
10+
pull_request_review:
11+
types: [submitted]
12+
13+
jobs:
14+
claude:
15+
if: |
16+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') &&
17+
(github.event.comment.user.login == github.repository_owner || github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR')) ||
18+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') &&
19+
(github.event.comment.user.login == github.repository_owner || github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR')) ||
20+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') &&
21+
(github.event.review.user.login == github.repository_owner || github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'MEMBER' || github.event.review.author_association == 'COLLABORATOR')) ||
22+
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
23+
(github.event.issue.user.login == github.repository_owner || github.event.issue.author_association == 'OWNER' || github.event.issue.author_association == 'MEMBER' || github.event.issue.author_association == 'COLLABORATOR'))
24+
runs-on: ubuntu-latest
25+
timeout-minutes: 30
26+
permissions:
27+
contents: read
28+
pull-requests: read
29+
issues: read
30+
id-token: write
31+
actions: read
32+
steps:
33+
- name: Checkout repository
34+
uses: actions/checkout@v4
35+
with:
36+
fetch-depth: 1
37+
38+
- name: Run Claude Code
39+
id: claude
40+
uses: anthropics/claude-code-action@094bd24d575e7b30ac1576024817bf1a97c81262 # v1
41+
with:
42+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
43+
additional_permissions: |
44+
actions: read

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,13 @@ mantle validate workflow.yaml # Offline schema validation
6868
mantle plan workflow.yaml # Diff against applied version
6969
mantle apply workflow.yaml # Apply versioned definition
7070
mantle run <workflow> # Manual trigger
71-
mantle cancel <execution-id> # Cancel running workflow
71+
mantle cancel <execution-id> # Cancel running workflow
72+
mantle retry <execution-id> # Retry from failed step
73+
mantle rollback <workflow> # Rollback to previous version
7274
mantle logs <execution-id> # View execution logs
7375
mantle status <execution-id> # View execution state
7476
mantle secrets create # Create typed credential
77+
mantle secrets rotate-key # Re-encrypt credentials with new key
7578
mantle serve # Start persistent server
7679
```
7780

packages/engine/docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ services:
5757
MANTLE_DATABASE_URL: postgres://mantle:mantle@postgres:5432/mantle?sslmode=disable
5858
MANTLE_API_ADDRESS: ":8080"
5959
MANTLE_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
60-
MANTLE_TMP_TYPE: s3
61-
MANTLE_TMP_BUCKET: mantle-artifacts
62-
MANTLE_TMP_PREFIX: artifacts/
60+
MANTLE_STORAGE_TYPE: s3
61+
MANTLE_STORAGE_BUCKET: mantle-artifacts
62+
MANTLE_STORAGE_PREFIX: artifacts/
6363
# Point S3 client at MinIO
6464
AWS_ACCESS_KEY_ID: minioadmin
6565
AWS_SECRET_ACCESS_KEY: minioadmin

packages/engine/internal/artifact/reaper.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import (
99
"time"
1010
)
1111

12-
// Reaper cleans up expired artifacts from both tmp storage and the database.
12+
// Reaper cleans up expired artifacts from both storage and the database.
1313
type Reaper struct {
1414
Store *Store
15-
TmpStorage TmpStorage
15+
Storage Storage
1616
Retention time.Duration
1717
Logger *slog.Logger
1818
}
@@ -40,8 +40,8 @@ func (r *Reaper) Sweep(ctx context.Context) (int, error) {
4040

4141
cleaned := 0
4242
for _, a := range expired {
43-
// Delete file from tmp storage.
44-
if delErr := r.TmpStorage.Delete(ctx, a.URL); delErr != nil {
43+
// Delete file from storage.
44+
if delErr := r.Storage.Delete(ctx, a.URL); delErr != nil {
4545
// If the blob is already gone, still clean up the metadata.
4646
if !errors.Is(delErr, os.ErrNotExist) {
4747
logger.Error("failed to delete artifact file",

packages/engine/internal/artifact/reaper_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010

1111
func TestReaper_CleansExpiredArtifacts(t *testing.T) {
1212
dir := t.TempDir()
13-
fs := &FilesystemTmpStorage{BasePath: dir}
13+
fs := &FilesystemStorage{BasePath: dir}
1414
db := setupTestDB(t)
1515
store := &Store{DB: db}
1616
ctx := context.Background()
@@ -48,7 +48,7 @@ func TestReaper_CleansExpiredArtifacts(t *testing.T) {
4848

4949
reaper := &Reaper{
5050
Store: store,
51-
TmpStorage: fs,
51+
Storage: fs,
5252
Retention: 24 * time.Hour,
5353
}
5454

@@ -72,11 +72,11 @@ func TestReaper_CleansExpiredArtifacts(t *testing.T) {
7272

7373
func TestReaper_SkipsWhenRetentionZero(t *testing.T) {
7474
dir := t.TempDir()
75-
fs := &FilesystemTmpStorage{BasePath: dir}
75+
fs := &FilesystemStorage{BasePath: dir}
7676

7777
reaper := &Reaper{
7878
Store: &Store{}, // safe: Sweep returns early when Retention <= 0, before accessing Store.DB
79-
TmpStorage: fs,
79+
Storage: fs,
8080
Retention: 0,
8181
}
8282

packages/engine/internal/artifact/tmp.go renamed to packages/engine/internal/artifact/storage.go

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import (
99
"strings"
1010
)
1111

12-
// TmpStorage persists and retrieves artifact files.
13-
type TmpStorage interface {
14-
// Put copies a local file to tmp storage at the given key.
12+
// Storage persists and retrieves artifact files.
13+
type Storage interface {
14+
// Put copies a local file to storage at the given key.
1515
// Returns the URL or path where the file can be accessed.
1616
Put(ctx context.Context, key string, localPath string) (string, error)
1717

@@ -22,32 +22,39 @@ type TmpStorage interface {
2222
DeleteByPrefix(ctx context.Context, prefix string) error
2323
}
2424

25-
// FilesystemTmpStorage stores artifacts on the local filesystem.
26-
type FilesystemTmpStorage struct {
25+
// FilesystemStorage stores artifacts on the local filesystem.
26+
type FilesystemStorage struct {
2727
BasePath string
2828
}
2929

3030
// Put copies the file at localPath to the storage location identified by key and returns its path.
31-
func (fs *FilesystemTmpStorage) Put(ctx context.Context, key string, localPath string) (string, error) {
31+
func (fs *FilesystemStorage) Put(ctx context.Context, key string, localPath string) (string, error) {
32+
// Reject obviously malicious keys before any filesystem operations.
33+
if filepath.IsAbs(key) || key == ".." || strings.HasPrefix(key, ".."+string(filepath.Separator)) {
34+
return "", fmt.Errorf("invalid artifact key: must be a relative path within the storage directory")
35+
}
36+
3237
destPath := filepath.Join(fs.BasePath, key)
3338
// Validate destination is within BasePath to prevent path traversal.
34-
absBase, err := filepath.Abs(fs.BasePath)
39+
// Use EvalSymlinks for canonicalization to prevent symlink-based escapes.
40+
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
3541
if err != nil {
3642
return "", fmt.Errorf("resolving base path: %w", err)
3743
}
38-
absDest, err := filepath.Abs(destPath)
44+
// Ensure dest directory exists before resolving symlinks on the full path.
45+
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
46+
return "", fmt.Errorf("creating artifact dir: %w", err)
47+
}
48+
resolvedDest, err := filepath.EvalSymlinks(filepath.Dir(destPath))
3949
if err != nil {
4050
return "", fmt.Errorf("resolving dest path: %w", err)
4151
}
42-
rel, err := filepath.Rel(absBase, absDest)
52+
resolvedDest = filepath.Join(resolvedDest, filepath.Base(destPath))
53+
rel, err := filepath.Rel(resolvedBase, resolvedDest)
4354
if err != nil || strings.HasPrefix(rel, "..") {
4455
return "", fmt.Errorf("key escapes base path")
4556
}
4657

47-
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
48-
return "", fmt.Errorf("creating artifact dir: %w", err)
49-
}
50-
5158
// Reject symlinks and non-regular files to prevent exfiltration.
5259
fi, err := os.Lstat(localPath)
5360
if err != nil {
@@ -79,38 +86,57 @@ func (fs *FilesystemTmpStorage) Put(ctx context.Context, key string, localPath s
7986
}
8087

8188
// DeleteByPrefix removes all files stored under the given key prefix.
82-
func (fs *FilesystemTmpStorage) DeleteByPrefix(ctx context.Context, prefix string) error {
89+
func (fs *FilesystemStorage) DeleteByPrefix(ctx context.Context, prefix string) error {
8390
target := filepath.Join(fs.BasePath, prefix)
84-
absBase, err := filepath.Abs(fs.BasePath)
91+
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
8592
if err != nil {
8693
return fmt.Errorf("resolving base path: %w", err)
8794
}
88-
absTarget, err := filepath.Abs(target)
95+
resolvedTarget, err := filepath.EvalSymlinks(target)
8996
if err != nil {
97+
// Target doesn't exist — compute candidate relative to resolvedBase for traversal check.
98+
candidate := filepath.Join(resolvedBase, prefix)
99+
rel, relErr := filepath.Rel(resolvedBase, candidate)
100+
if relErr != nil || strings.HasPrefix(rel, "..") {
101+
return fmt.Errorf("prefix escapes base path")
102+
}
103+
// Target doesn't exist and is within base — nothing to delete.
104+
if os.IsNotExist(err) {
105+
return nil
106+
}
90107
return fmt.Errorf("resolving target path: %w", err)
91108
}
92-
rel, err := filepath.Rel(absBase, absTarget)
109+
rel, err := filepath.Rel(resolvedBase, resolvedTarget)
93110
if err != nil || strings.HasPrefix(rel, "..") {
94111
return fmt.Errorf("prefix escapes base path")
95112
}
96-
return os.RemoveAll(target)
113+
return os.RemoveAll(resolvedTarget)
97114
}
98115

99116
// Delete removes a single artifact file by its path (as returned by Put).
100-
func (fs *FilesystemTmpStorage) Delete(ctx context.Context, url string) error {
101-
absBase, err := filepath.Abs(fs.BasePath)
117+
func (fs *FilesystemStorage) Delete(ctx context.Context, url string) error {
118+
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
102119
if err != nil {
103120
return fmt.Errorf("resolving base path: %w", err)
104121
}
105-
absURL, err := filepath.Abs(url)
122+
resolvedURL, err := filepath.EvalSymlinks(url)
106123
if err != nil {
124+
// File doesn't exist — compute path relative to resolvedBase for traversal check.
125+
// url is an absolute path returned by Put, so use it directly.
126+
rel, relErr := filepath.Rel(resolvedBase, url)
127+
if relErr != nil || strings.HasPrefix(rel, "..") {
128+
return fmt.Errorf("artifact path escapes base path")
129+
}
130+
if os.IsNotExist(err) {
131+
return nil // already gone
132+
}
107133
return fmt.Errorf("resolving artifact path: %w", err)
108134
}
109-
rel, err := filepath.Rel(absBase, absURL)
135+
rel, err := filepath.Rel(resolvedBase, resolvedURL)
110136
if err != nil || strings.HasPrefix(rel, "..") {
111137
return fmt.Errorf("artifact path escapes base path")
112138
}
113-
if err := os.Remove(absURL); err != nil && !os.IsNotExist(err) {
139+
if err := os.Remove(resolvedURL); err != nil && !os.IsNotExist(err) {
114140
return err
115141
}
116142
return nil

packages/engine/internal/artifact/tmp_test.go renamed to packages/engine/internal/artifact/storage_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import (
77
"testing"
88
)
99

10-
func TestFilesystemTmpStorage_PutAndGet(t *testing.T) {
10+
func TestFilesystemStorage_PutAndGet(t *testing.T) {
1111
dir := t.TempDir()
12-
fs := &FilesystemTmpStorage{BasePath: dir}
12+
fs := &FilesystemStorage{BasePath: dir}
1313

1414
ctx := context.Background()
1515
key := "test-workflow/exec-123/my-artifact/data.tar.gz"
@@ -40,9 +40,9 @@ func TestFilesystemTmpStorage_PutAndGet(t *testing.T) {
4040
}
4141
}
4242

43-
func TestFilesystemTmpStorage_Delete(t *testing.T) {
43+
func TestFilesystemStorage_Delete(t *testing.T) {
4444
dir := t.TempDir()
45-
fs := &FilesystemTmpStorage{BasePath: dir}
45+
fs := &FilesystemStorage{BasePath: dir}
4646
ctx := context.Background()
4747

4848
// Write a file
@@ -68,9 +68,9 @@ func TestFilesystemTmpStorage_Delete(t *testing.T) {
6868
}
6969
}
7070

71-
func TestFilesystemTmpStorage_DeleteEscapeProtection(t *testing.T) {
71+
func TestFilesystemStorage_DeleteEscapeProtection(t *testing.T) {
7272
dir := t.TempDir()
73-
fs := &FilesystemTmpStorage{BasePath: dir}
73+
fs := &FilesystemStorage{BasePath: dir}
7474
ctx := context.Background()
7575

7676
err := fs.DeleteByPrefix(ctx, "../../etc")

packages/engine/internal/audit/audit.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
ActionStepContinuedOnError Action = "step.continued_on_error"
1919
ActionExecutionCancelled Action = "execution.cancelled"
2020
ActionChildWorkflowExecuted Action = "workflow.child_executed"
21+
ActionExecutionRetried Action = "execution.retried"
2122
ActionArtifactPersisted Action = "artifact.persisted"
2223

2324
// Admin operations.
@@ -30,18 +31,30 @@ const (
3031
ActionAPIKeyRevoked Action = "apikey.revoked"
3132
ActionCredentialCreated Action = "credential.created"
3233
ActionCredentialDeleted Action = "credential.deleted"
33-
ActionCredentialRotated Action = "credential.rotated"
34+
ActionCredentialRotated Action = "credential.rotated"
35+
ActionSecretKeyRotated Action = "secret.key_rotated"
3436
ActionAuthFailed Action = "auth.failed"
3537

3638
// Email trigger operations.
3739
ActionEmailTriggerFired Action = "email.trigger.fired"
3840
ActionEmailConnectionEstablished Action = "email.connection.established"
3941
ActionEmailConnectionFailed Action = "email.connection.failed"
4042

43+
// Hook operations.
44+
ActionHookStepStarted Action = "hook.step.started"
45+
ActionHookStepCompleted Action = "hook.step.completed"
46+
ActionHookStepFailed Action = "hook.step.failed"
47+
4148
// Budget operations.
4249
ActionBudgetExceeded Action = "budget.exceeded"
4350
ActionBudgetWarning Action = "budget.warning"
4451
ActionBudgetUpdated Action = "budget.updated"
52+
53+
// Promotion operations.
54+
ActionExecutionPromoted Action = "execution.promoted"
55+
56+
// Rollback operations.
57+
ActionWorkflowRolledBack Action = "workflow.rolled_back"
4558
)
4659

4760
// Resource identifies the target of an audit event.

packages/engine/internal/audit/postgres.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ func (p *PostgresEmitter) enrichFromContext(ctx context.Context, event Event) Ev
3333
func (p *PostgresEmitter) Emit(ctx context.Context, event Event) error {
3434
// Enrich event metadata with auth context if available.
3535
event = p.enrichFromContext(ctx, event)
36+
return emitEvent(ctx, p.DB, event)
37+
}
38+
39+
// execer abstracts ExecContext so both *sql.DB and *sql.Tx can emit audit events.
40+
type execer interface {
41+
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
42+
}
43+
44+
// EmitTx emits an audit event using an existing transaction.
45+
// This ensures the audit record is committed atomically with the operation it describes.
46+
// If emitter is non-nil, the event is enriched with auth context before inserting.
47+
func EmitTx(ctx context.Context, tx *sql.Tx, event Event, emitter ...*PostgresEmitter) error {
48+
if len(emitter) > 0 && emitter[0] != nil {
49+
event = emitter[0].enrichFromContext(ctx, event)
50+
}
51+
return emitEvent(ctx, tx, event)
52+
}
53+
54+
func emitEvent(ctx context.Context, db execer, event Event) error {
3655
beforeJSON, err := marshalNullableJSON(event.Before)
3756
if err != nil {
3857
return fmt.Errorf("marshaling before state: %w", err)
@@ -61,7 +80,7 @@ func (p *PostgresEmitter) Emit(ctx context.Context, event Event) error {
6180
teamID = event.TeamID
6281
}
6382

64-
_, err = p.DB.ExecContext(ctx,
83+
_, err = db.ExecContext(ctx,
6584
`INSERT INTO audit_events (timestamp, actor, action, resource_type, resource_id, before_state, after_state, metadata, team_id)
6685
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
6786
ts, event.Actor, string(event.Action), event.Resource.Type, event.Resource.ID,

0 commit comments

Comments
 (0)