Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e0e1b16
feat(config): add version field with validation (#52)
michaelmcnees Mar 27, 2026
e6d87ff
feat(config): rename tmp section to storage with deprecation fallback…
michaelmcnees Mar 27, 2026
58c10cb
feat(secrets): global key rotation with caller-managed transaction (#51)
michaelmcnees Mar 27, 2026
b5ce224
feat(db): add migration 016 for v0.4.0 safety net features
michaelmcnees Mar 27, 2026
22be93b
feat(workflow): add concurrency fields, hooks schema, and validation …
michaelmcnees Mar 27, 2026
f4c8ced
feat(engine): concurrency controls with advisory locks and queue prom…
michaelmcnees Mar 27, 2026
84f81e8
feat(cel): add hooks and execution variables to CEL environment (#30)
michaelmcnees Mar 27, 2026
e9107f9
feat(engine): lifecycle hooks — on_success, on_failure, on_finish (#30)
michaelmcnees Mar 27, 2026
91f1e77
feat(engine): promote queued executions after hooks complete (#49, #30)
michaelmcnees Mar 27, 2026
c36028a
feat(cli): mantle retry — resume from failed step (#48)
michaelmcnees Mar 27, 2026
304b1d2
feat(cli): mantle rollback — revert workflow to previous version (#50)
michaelmcnees Mar 27, 2026
916b2c6
docs: document v0.4.0 features — hooks, concurrency, retry, rollback
michaelmcnees Mar 27, 2026
44d82e5
fix: address code review findings — transactions, validation, indexes
michaelmcnees Mar 27, 2026
7fb6f97
fix: use fresh context for queue promotion after timeout (security re…
michaelmcnees Mar 27, 2026
e01d15e
fix: wire concurrency limits into execution path (architecture review)
michaelmcnees Mar 27, 2026
f01c73a
fix: store unprefixed hook step names with proper unique constraint
michaelmcnees Mar 27, 2026
330ac33
test: add unit tests for concurrency, hooks, and retry engine logic
michaelmcnees Mar 27, 2026
8835835
fix: add stderr warning before printing sensitive key material
michaelmcnees Mar 27, 2026
d70bb83
fix: secure key rotation — no argv exposure, tx-backed audit, advisor…
michaelmcnees Mar 27, 2026
8b1a118
fix: atomic concurrency check+insert, audit on promotion (PR review)
michaelmcnees Mar 27, 2026
a2dfe8e
fix: hooks hardening, migration fixes, validation, security improveme…
michaelmcnees Mar 27, 2026
b82cac9
fix: PR review round 2 — config semantics, exit codes, audit, securit…
michaelmcnees Mar 27, 2026
4dc6c56
fix: JSON encode errors, encrypt under lock, docs accuracy (PR review…
michaelmcnees Mar 27, 2026
63c8853
fix: match rollback error example to actual code output
michaelmcnees Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,13 @@ mantle validate workflow.yaml # Offline schema validation
mantle plan workflow.yaml # Diff against applied version
mantle apply workflow.yaml # Apply versioned definition
mantle run <workflow> # Manual trigger
mantle cancel <execution-id> # Cancel running workflow
mantle cancel <execution-id> # Cancel running workflow
mantle retry <execution-id> # Retry from failed step
mantle rollback <workflow> # Rollback to previous version
mantle logs <execution-id> # View execution logs
mantle status <execution-id> # View execution state
mantle secrets create # Create typed credential
mantle secrets rotate-key # Re-encrypt credentials with new key
mantle serve # Start persistent server
```

Expand Down
6 changes: 3 additions & 3 deletions packages/engine/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ services:
MANTLE_DATABASE_URL: postgres://mantle:mantle@postgres:5432/mantle?sslmode=disable
MANTLE_API_ADDRESS: ":8080"
MANTLE_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
MANTLE_TMP_TYPE: s3
MANTLE_TMP_BUCKET: mantle-artifacts
MANTLE_TMP_PREFIX: artifacts/
MANTLE_STORAGE_TYPE: s3
MANTLE_STORAGE_BUCKET: mantle-artifacts
MANTLE_STORAGE_PREFIX: artifacts/
# Point S3 client at MinIO
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
Expand Down
8 changes: 4 additions & 4 deletions packages/engine/internal/artifact/reaper.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import (
"time"
)

// Reaper cleans up expired artifacts from both tmp storage and the database.
// Reaper cleans up expired artifacts from both storage and the database.
type Reaper struct {
Store *Store
TmpStorage TmpStorage
Storage Storage
Retention time.Duration
Logger *slog.Logger
}
Expand Down Expand Up @@ -40,8 +40,8 @@ func (r *Reaper) Sweep(ctx context.Context) (int, error) {

cleaned := 0
for _, a := range expired {
// Delete file from tmp storage.
if delErr := r.TmpStorage.Delete(ctx, a.URL); delErr != nil {
// Delete file from storage.
if delErr := r.Storage.Delete(ctx, a.URL); delErr != nil {
// If the blob is already gone, still clean up the metadata.
if !errors.Is(delErr, os.ErrNotExist) {
logger.Error("failed to delete artifact file",
Expand Down
8 changes: 4 additions & 4 deletions packages/engine/internal/artifact/reaper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

func TestReaper_CleansExpiredArtifacts(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}
fs := &FilesystemStorage{BasePath: dir}
db := setupTestDB(t)
store := &Store{DB: db}
ctx := context.Background()
Expand Down Expand Up @@ -48,7 +48,7 @@ func TestReaper_CleansExpiredArtifacts(t *testing.T) {

reaper := &Reaper{
Store: store,
TmpStorage: fs,
Storage: fs,
Retention: 24 * time.Hour,
}

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

func TestReaper_SkipsWhenRetentionZero(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}
fs := &FilesystemStorage{BasePath: dir}

reaper := &Reaper{
Store: &Store{}, // safe: Sweep returns early when Retention <= 0, before accessing Store.DB
TmpStorage: fs,
Storage: fs,
Retention: 0,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import (
"strings"
)

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

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

// FilesystemTmpStorage stores artifacts on the local filesystem.
type FilesystemTmpStorage struct {
// FilesystemStorage stores artifacts on the local filesystem.
type FilesystemStorage struct {
BasePath string
}

// Put copies the file at localPath to the storage location identified by key and returns its path.
func (fs *FilesystemTmpStorage) Put(ctx context.Context, key string, localPath string) (string, error) {
func (fs *FilesystemStorage) Put(ctx context.Context, key string, localPath string) (string, error) {
// Reject obviously malicious keys before any filesystem operations.
if filepath.IsAbs(key) || key == ".." || strings.HasPrefix(key, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid artifact key: must be a relative path within the storage directory")
}

destPath := filepath.Join(fs.BasePath, key)
// Validate destination is within BasePath to prevent path traversal.
absBase, err := filepath.Abs(fs.BasePath)
// Use EvalSymlinks for canonicalization to prevent symlink-based escapes.
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
if err != nil {
return "", fmt.Errorf("resolving base path: %w", err)
}
absDest, err := filepath.Abs(destPath)
// Ensure dest directory exists before resolving symlinks on the full path.
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return "", fmt.Errorf("creating artifact dir: %w", err)
}
resolvedDest, err := filepath.EvalSymlinks(filepath.Dir(destPath))
if err != nil {
return "", fmt.Errorf("resolving dest path: %w", err)
}
rel, err := filepath.Rel(absBase, absDest)
resolvedDest = filepath.Join(resolvedDest, filepath.Base(destPath))
rel, err := filepath.Rel(resolvedBase, resolvedDest)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("key escapes base path")
}

if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return "", fmt.Errorf("creating artifact dir: %w", err)
}

// Reject symlinks and non-regular files to prevent exfiltration.
fi, err := os.Lstat(localPath)
if err != nil {
Expand Down Expand Up @@ -79,38 +86,57 @@ func (fs *FilesystemTmpStorage) Put(ctx context.Context, key string, localPath s
}

// DeleteByPrefix removes all files stored under the given key prefix.
func (fs *FilesystemTmpStorage) DeleteByPrefix(ctx context.Context, prefix string) error {
func (fs *FilesystemStorage) DeleteByPrefix(ctx context.Context, prefix string) error {
target := filepath.Join(fs.BasePath, prefix)
absBase, err := filepath.Abs(fs.BasePath)
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
if err != nil {
return fmt.Errorf("resolving base path: %w", err)
}
absTarget, err := filepath.Abs(target)
resolvedTarget, err := filepath.EvalSymlinks(target)
if err != nil {
// Target doesn't exist — compute candidate relative to resolvedBase for traversal check.
candidate := filepath.Join(resolvedBase, prefix)
rel, relErr := filepath.Rel(resolvedBase, candidate)
if relErr != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("prefix escapes base path")
}
// Target doesn't exist and is within base — nothing to delete.
if os.IsNotExist(err) {
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return fmt.Errorf("resolving target path: %w", err)
}
rel, err := filepath.Rel(absBase, absTarget)
rel, err := filepath.Rel(resolvedBase, resolvedTarget)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("prefix escapes base path")
}
return os.RemoveAll(target)
return os.RemoveAll(resolvedTarget)
}

// Delete removes a single artifact file by its path (as returned by Put).
func (fs *FilesystemTmpStorage) Delete(ctx context.Context, url string) error {
absBase, err := filepath.Abs(fs.BasePath)
func (fs *FilesystemStorage) Delete(ctx context.Context, url string) error {
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
if err != nil {
return fmt.Errorf("resolving base path: %w", err)
}
absURL, err := filepath.Abs(url)
resolvedURL, err := filepath.EvalSymlinks(url)
if err != nil {
// File doesn't exist — compute path relative to resolvedBase for traversal check.
// url is an absolute path returned by Put, so use it directly.
rel, relErr := filepath.Rel(resolvedBase, url)
if relErr != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("artifact path escapes base path")
}
if os.IsNotExist(err) {
return nil // already gone
}
return fmt.Errorf("resolving artifact path: %w", err)
}
rel, err := filepath.Rel(absBase, absURL)
rel, err := filepath.Rel(resolvedBase, resolvedURL)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("artifact path escapes base path")
}
if err := os.Remove(absURL); err != nil && !os.IsNotExist(err) {
if err := os.Remove(resolvedURL); err != nil && !os.IsNotExist(err) {
return err
}
return nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import (
"testing"
)

func TestFilesystemTmpStorage_PutAndGet(t *testing.T) {
func TestFilesystemStorage_PutAndGet(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}
fs := &FilesystemStorage{BasePath: dir}

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

func TestFilesystemTmpStorage_Delete(t *testing.T) {
func TestFilesystemStorage_Delete(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}
fs := &FilesystemStorage{BasePath: dir}
ctx := context.Background()

// Write a file
Expand All @@ -68,9 +68,9 @@ func TestFilesystemTmpStorage_Delete(t *testing.T) {
}
}

func TestFilesystemTmpStorage_DeleteEscapeProtection(t *testing.T) {
func TestFilesystemStorage_DeleteEscapeProtection(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}
fs := &FilesystemStorage{BasePath: dir}
ctx := context.Background()

err := fs.DeleteByPrefix(ctx, "../../etc")
Expand Down
15 changes: 14 additions & 1 deletion packages/engine/internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
ActionStepSkipped Action = "step.skipped"
ActionStepContinuedOnError Action = "step.continued_on_error"
ActionExecutionCancelled Action = "execution.cancelled"
ActionExecutionRetried Action = "execution.retried"
ActionArtifactPersisted Action = "artifact.persisted"

// Admin operations.
Expand All @@ -29,18 +30,30 @@ const (
ActionAPIKeyRevoked Action = "apikey.revoked"
ActionCredentialCreated Action = "credential.created"
ActionCredentialDeleted Action = "credential.deleted"
ActionCredentialRotated Action = "credential.rotated"
ActionCredentialRotated Action = "credential.rotated"
ActionSecretKeyRotated Action = "secret.key_rotated"
ActionAuthFailed Action = "auth.failed"

// Email trigger operations.
ActionEmailTriggerFired Action = "email.trigger.fired"
ActionEmailConnectionEstablished Action = "email.connection.established"
ActionEmailConnectionFailed Action = "email.connection.failed"

// Hook operations.
ActionHookStepStarted Action = "hook.step.started"
ActionHookStepCompleted Action = "hook.step.completed"
ActionHookStepFailed Action = "hook.step.failed"

// Budget operations.
ActionBudgetExceeded Action = "budget.exceeded"
ActionBudgetWarning Action = "budget.warning"
ActionBudgetUpdated Action = "budget.updated"

// Promotion operations.
ActionExecutionPromoted Action = "execution.promoted"

// Rollback operations.
ActionWorkflowRolledBack Action = "workflow.rolled_back"
)

// Resource identifies the target of an audit event.
Expand Down
21 changes: 20 additions & 1 deletion packages/engine/internal/audit/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ func (p *PostgresEmitter) enrichFromContext(ctx context.Context, event Event) Ev
func (p *PostgresEmitter) Emit(ctx context.Context, event Event) error {
// Enrich event metadata with auth context if available.
event = p.enrichFromContext(ctx, event)
return emitEvent(ctx, p.DB, event)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// execer abstracts ExecContext so both *sql.DB and *sql.Tx can emit audit events.
type execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

// EmitTx emits an audit event using an existing transaction.
// This ensures the audit record is committed atomically with the operation it describes.
// If emitter is non-nil, the event is enriched with auth context before inserting.
func EmitTx(ctx context.Context, tx *sql.Tx, event Event, emitter ...*PostgresEmitter) error {
if len(emitter) > 0 && emitter[0] != nil {
event = emitter[0].enrichFromContext(ctx, event)
}
return emitEvent(ctx, tx, event)
}

func emitEvent(ctx context.Context, db execer, event Event) error {
beforeJSON, err := marshalNullableJSON(event.Before)
if err != nil {
return fmt.Errorf("marshaling before state: %w", err)
Expand Down Expand Up @@ -61,7 +80,7 @@ func (p *PostgresEmitter) Emit(ctx context.Context, event Event) error {
teamID = event.TeamID
}

_, err = p.DB.ExecContext(ctx,
_, err = db.ExecContext(ctx,
`INSERT INTO audit_events (timestamp, actor, action, resource_type, resource_id, before_state, after_state, metadata, team_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
ts, event.Actor, string(event.Action), event.Resource.Type, event.Resource.ID,
Expand Down
16 changes: 16 additions & 0 deletions packages/engine/internal/cel/cel.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type Context struct {
Inputs map[string]any // inputs.<name> → workflow inputs
Trigger map[string]any // trigger.payload → webhook trigger data
Artifacts map[string]map[string]any // artifacts.<name> → {name, url, size}
Hooks map[string]map[string]any // hooks.<name>.output → hook step outputs
Execution map[string]any // execution.status, execution.error, etc.
}

const maxProgramCacheSize = 10000
Expand All @@ -38,6 +40,8 @@ func NewEvaluator() (*Evaluator, error) {
cel.Variable("env", cel.MapType(cel.StringType, cel.StringType)),
cel.Variable("trigger", cel.MapType(cel.StringType, cel.DynType)),
cel.Variable("artifacts", cel.MapType(cel.StringType, cel.DynType)),
cel.Variable("hooks", cel.MapType(cel.StringType, cel.DynType)),
cel.Variable("execution", cel.MapType(cel.StringType, cel.DynType)),
}
opts = append(opts, customFunctions()...)

Expand Down Expand Up @@ -66,12 +70,24 @@ func (e *Evaluator) Eval(expression string, ctx *Context) (any, error) {
artifacts = map[string]map[string]any{}
}

hooks := ctx.Hooks
if hooks == nil {
hooks = map[string]map[string]any{}
}

execution := ctx.Execution
if execution == nil {
execution = map[string]any{}
}

vars := map[string]any{
"steps": ctx.Steps,
"inputs": ctx.Inputs,
"env": e.envCache,
"trigger": trigger,
"artifacts": artifacts,
"hooks": hooks,
"execution": execution,
}

out, _, err := prog.Eval(vars)
Expand Down
Loading
Loading