multica-ai/multica · error

task-local CLI config directory %q escapes root %q

Error message

task-local CLI config directory %q escapes root %q

What it means

A defensive invariant inside the task-local permission walk: the code ascends from the config directory via filepath.Dir until it reaches the task root, and if it reaches the filesystem root ('/') without matching the configured MULTICA_TASK_CONFIG_ROOT it aborts. This happens when the resolved config path is not actually under the task root — e.g. the root env var and the profile path disagree, or symlinks make the textual prefix comparison fail.

Source

Thrown at server/internal/cli/config.go:344

	}
	if err := os.MkdirAll(dir, dirMode); err != nil {
		return fmt.Errorf("create CLI config directory: %w", err)
	}
	if dirMode == 0o700 {
		root, _, err := multicaConfigRoot()
		if err != nil {
			return fmt.Errorf("resolve task-local CLI config root: %w", err)
		}
		for current := dir; ; current = filepath.Dir(current) {
			if err := os.Chmod(current, 0o700); err != nil {
				return fmt.Errorf("restrict task-local CLI config directory: %w", err)
			}
			if current == root {
				break
			}
			parent := filepath.Dir(current)
			if parent == current {
				return fmt.Errorf("task-local CLI config directory %q escapes root %q", dir, root)
			}
		}
	}
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return fmt.Errorf("encode CLI config: %w", err)
	}

	// Write to a temp file in the same directory, then rename for atomicity.
	tmp, err := os.CreateTemp(dir, ".config-*.json.tmp")
	if err != nil {
		return fmt.Errorf("create temp config file: %w", err)
	}
	tmpPath := tmp.Name()
	if _, err := tmp.Write(append(data, '\n')); err != nil {
		tmp.Close()
		os.Remove(tmpPath)
		return fmt.Errorf("write temp config file: %w", err)

View on GitHub (pinned to 2c0912b6ec)

Solutions

  1. Set MULTICA_TASK_CONFIG_ROOT to the cleaned, canonical absolute path with no trailing slash or '..' components (e.g. /var/lib/multica/tasks)
  2. Resolve symlinks first: realpath the intended root and export that value
  3. Verify with a quick check that the config dir path starts with the exported root value plus a path separator
  4. If the escaping is unexpected, inspect CLIConfigPathForProfile's output for the active profile to see which side is wrong

Example fix

# before
export MULTICA_TASK_CONFIG_ROOT=/var/lib/multica/tasks/
# config dir resolves to /var/lib/multica/tasks/profiles/dev -> root never matches

# after
export MULTICA_TASK_CONFIG_ROOT=$(realpath /var/lib/multica/tasks)
Defensive patterns

Strategy: validation

Validate before calling

root := filepath.Clean(os.Getenv("MULTICA_TASK_CONFIG_ROOT"))
if resolved, err := filepath.EvalSymlinks(root); err == nil {
	os.Setenv("MULTICA_TASK_CONFIG_ROOT", resolved)
}

Prevention

When it happens

Trigger: MULTICA_TASK_CONFIG_ROOT containing a path that is not a lexical prefix of the computed config dir: trailing-slash vs cleaned-path mismatches, a symlinked home directory so the cleaned path never equals the root string, or '..' components surviving in the env var.

Common situations: Setting MULTICA_TASK_CONFIG_ROOT=/var/lib/multica/tasks/ (trailing slash) while the config path resolves to /var/lib/multica/tasks/profiles/x; /home pointing at /users via symlink on macOS.

Related errors


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/d50b0ba1b322b3c8. Report an issue: GitHub.