sipeed/picoclaw · error

credential: file:// path escapes config directory

Error message

credential: file:// path escapes config directory

What it means

Returned by Resolver.Resolve as a deliberate security guard: after resolving symlinks, the real path of the credential file is not inside the resolver's base config directory. Because EvalSymlinks ran first, this catches both lexical escapes (../ in the filename) and symlink-based escapes (a link inside configDir pointing outside). This is fail-closed: no credential is read, and the error is returned immediately.

Source

Thrown at pkg/credential/credential.go:133

	if strings.HasPrefix(raw, FileScheme) {
		fileName := strings.TrimSpace(strings.TrimPrefix(raw, FileScheme))
		if fileName == "" {
			return "", fmt.Errorf("credential: file:// reference has no filename")
		}

		baseDir := r.resolvedConfigDir
		if baseDir == "" {
			baseDir = r.configDir
		}
		keyPath := filepath.Join(baseDir, fileName)
		// Resolve symlinks before enforcing containment to prevent escaping via symlinks.
		realKeyPath, err := filepath.EvalSymlinks(keyPath)
		if err != nil {
			return "", fmt.Errorf("credential: failed to resolve credential file path %q: %w", keyPath, err)
		}
		if !isWithinDir(realKeyPath, baseDir) {
			return "", fmt.Errorf("credential: file:// path escapes config directory")
		}
		data, err := os.ReadFile(realKeyPath)
		if err != nil {
			return "", fmt.Errorf("credential: failed to read credential file %q: %w", realKeyPath, err)
		}

		value := strings.TrimSpace(string(data))
		if value == "" {
			return "", fmt.Errorf("credential: credential file %q is empty", realKeyPath)
		}

		return value, nil
	}

	if strings.HasPrefix(raw, EncScheme) {
		return resolveEncrypted(raw)
	}

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Copy (not symlink) the credential file into the config dir and reference it plainly: `file://openai.key`
  2. If you need mounted secrets, have provisioning materialize the file inside the config dir (e.g. initContainer/sidecar copy step) rather than linking out
  3. Remove any symlinks inside the config dir that point outside it — they will always be rejected
  4. Never construct file:// values from user input without rejecting `..` and absolute-ish components first

Example fix

# before: symlink escape
ln -s /run/secrets/api_key config/openai.key   # always rejected

# after: copy into config dir
cp /run/secrets/api_key config/openai.key && chmod 600 config/openai.key
Defensive patterns

Strategy: validation

Validate before calling

// Reject traversal before calling Resolve.
func safeFileRef(filename string) error {
	filename = strings.TrimSpace(filename)
	if filename == "" || strings.Contains(filename, "..") || filepath.IsAbs(filename) {
		return fmt.Errorf("unsafe credential filename %q", filename)
	}
	return nil
}

Type guard

func isContainedFilename(name string) bool {
	name = strings.TrimSpace(name)
	return name != "" && !strings.Contains(name, "..") && !filepath.IsAbs(name)
}

Try / catch

if _, err := resolver.Resolve(raw); err != nil {
	if strings.Contains(err.Error(), "escapes config directory") {
		// never retry with sanitized input automatically; surface for human review (security)
	}
	return err
}

Prevention

When it happens

Trigger: `file://../../etc/passwd` or `file://../shared/keys/openai.key` (lexical traversal after join+resolve lands outside baseDir); or a legit-looking `file://openai.key` where openai.key is a symlink to ~/.ssh/id_rsa or /run/secrets/... — EvalSymlinks exposes the real target outside the dir and the guard fires.

Common situations: Trying to reuse existing secrets (Docker/Kubernetes mounted at /run/secrets, ~/.ssh) by symlinking them into the config dir; sharing a keys directory between projects via ../; or hardening scans probing with traversal payloads. The design requires secrets to physically live inside the config dir.

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/66c5842e1ccf1824. Report an issue: GitHub.