mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
301 lines
9.2 KiB
Go
301 lines
9.2 KiB
Go
package execenv
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/pelletier/go-toml/v2"
|
|
"github.com/pelletier/go-toml/v2/unstable"
|
|
)
|
|
|
|
// Codex filters environment variables before running shell tool subprocesses.
|
|
// Its default secret guard drops names containing KEY, SECRET, or TOKEN, so a
|
|
// daemon-spawned Codex process can have MULTICA_TOKEN while `multica issue ...`
|
|
// inside a shell tool does not. The daemon therefore owns the shell policy in
|
|
// each task's isolated Codex home.
|
|
const (
|
|
shellEnvironmentPolicyKey = "shell_environment_policy"
|
|
multicaShellEnvBeginMarker = "# BEGIN multica-managed shell-environment (do not edit; regenerated by daemon)"
|
|
multicaShellEnvEndMarker = "# END multica-managed shell-environment"
|
|
)
|
|
|
|
var shellEnvBlockRe = regexp.MustCompile(
|
|
`(?ms)^` + regexp.QuoteMeta(multicaShellEnvBeginMarker) +
|
|
`.*?^` + regexp.QuoteMeta(multicaShellEnvEndMarker) + `\n*`)
|
|
|
|
type codexShellEnvironmentPolicy struct {
|
|
Inherit string `toml:"inherit"`
|
|
IgnoreDefaultExcludes bool `toml:"ignore_default_excludes"`
|
|
IncludeOnly []string `toml:"include_only"`
|
|
}
|
|
|
|
type tomlByteRange struct {
|
|
start int
|
|
end int
|
|
}
|
|
|
|
// CodexShellEnvAllowlist returns exact (not glob) environment names for the
|
|
// managed shell policy.
|
|
//
|
|
// Inherited process variables keep Codex's documented default filtering for
|
|
// names containing KEY, SECRET, or TOKEN. Credential-looking explicit values
|
|
// are included only when their names also appear in authorizedExplicit, which
|
|
// daemon.go derives solely from the current agent's blocklist-checked
|
|
// custom_env. Inherited MULTICA_* variables are always dropped because they
|
|
// belong to the daemon process, not necessarily to this task. Explicit
|
|
// MULTICA_* values are safe to include because daemon.go blocklists that
|
|
// namespace from agent custom_env and constructs those values from the current
|
|
// task.
|
|
func CodexShellEnvAllowlist(inherited []string, explicit map[string]string, authorizedExplicit []string) []string {
|
|
authorized := make(map[string]struct{}, len(authorizedExplicit))
|
|
for _, key := range authorizedExplicit {
|
|
if key != "" {
|
|
authorized[strings.ToUpper(key)] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Codex's glob matching is case-insensitive. De-duplicate on the same basis
|
|
// so Windows Path/PATH aliases cannot create ambiguous policy entries.
|
|
allowed := make(map[string]string, len(inherited)+len(explicit))
|
|
add := func(key string, isExplicit bool) {
|
|
if key == "" {
|
|
return
|
|
}
|
|
upper := strings.ToUpper(key)
|
|
if strings.HasPrefix(upper, "MULTICA_") {
|
|
if !isExplicit {
|
|
return
|
|
}
|
|
} else if codexDefaultExcludesEnvKey(upper) {
|
|
if !isExplicit {
|
|
return
|
|
}
|
|
if _, ok := authorized[upper]; !ok {
|
|
return
|
|
}
|
|
}
|
|
allowed[upper] = key
|
|
}
|
|
|
|
for _, entry := range inherited {
|
|
key, _, ok := strings.Cut(entry, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
add(key, false)
|
|
}
|
|
for key := range explicit {
|
|
add(key, true)
|
|
}
|
|
|
|
keys := make([]string, 0, len(allowed))
|
|
for _, key := range allowed {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
return strings.ToUpper(keys[i]) < strings.ToUpper(keys[j])
|
|
})
|
|
return keys
|
|
}
|
|
|
|
func codexDefaultExcludesEnvKey(upperKey string) bool {
|
|
return strings.Contains(upperKey, "KEY") ||
|
|
strings.Contains(upperKey, "SECRET") ||
|
|
strings.Contains(upperKey, "TOKEN")
|
|
}
|
|
|
|
func renderMulticaShellEnvBlock(includeOnly []string) (string, error) {
|
|
policy, err := toml.Marshal(codexShellEnvironmentPolicy{
|
|
Inherit: "all",
|
|
IgnoreDefaultExcludes: true,
|
|
IncludeOnly: includeOnly,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("render shell_environment_policy: %w", err)
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(multicaShellEnvBeginMarker)
|
|
b.WriteByte('\n')
|
|
b.WriteString("[")
|
|
b.WriteString(shellEnvironmentPolicyKey)
|
|
b.WriteString("]\n")
|
|
b.Write(bytes.TrimRight(policy, "\n"))
|
|
b.WriteByte('\n')
|
|
b.WriteString(multicaShellEnvEndMarker)
|
|
b.WriteByte('\n')
|
|
return b.String(), nil
|
|
}
|
|
|
|
// stripUserShellEnvPolicy removes complete TOML expressions that define a
|
|
// shell_environment_policy, including root dotted/inline values, quoted table
|
|
// forms, nested set tables, and profile overlays. It deliberately preserves
|
|
// comments and every unrelated expression byte-for-byte.
|
|
func stripUserShellEnvPolicy(content []byte) ([]byte, error) {
|
|
if len(bytes.TrimSpace(content)) == 0 {
|
|
return content, nil
|
|
}
|
|
|
|
// The iterative parser exposes source ranges but does not enforce all TOML
|
|
// semantic rules (for example duplicate definitions). Validate with the
|
|
// stable decoder first so a malformed user config is never partially
|
|
// rewritten into a different malformed config.
|
|
var decoded map[string]any
|
|
if err := toml.Unmarshal(content, &decoded); err != nil {
|
|
return nil, fmt.Errorf("parse config.toml before shell environment update: %w", err)
|
|
}
|
|
|
|
var parser unstable.Parser
|
|
parser.Reset(content)
|
|
var currentTable []string
|
|
ranges := make([]tomlByteRange, 0, 8)
|
|
for parser.NextExpression() {
|
|
expr := parser.Expression()
|
|
keys := tomlNodeKeys(expr)
|
|
switch expr.Kind {
|
|
case unstable.Table, unstable.ArrayTable:
|
|
currentTable = keys
|
|
if isCodexShellEnvPolicyPath(keys) {
|
|
r, err := tomlTableHeaderLineRange(content, expr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ranges = append(ranges, r)
|
|
}
|
|
case unstable.KeyValue:
|
|
fullKey := make([]string, 0, len(currentTable)+len(keys))
|
|
fullKey = append(fullKey, currentTable...)
|
|
fullKey = append(fullKey, keys...)
|
|
if isCodexShellEnvPolicyPath(fullKey) {
|
|
start := int(expr.Raw.Offset)
|
|
ranges = append(ranges, tomlByteRange{start: start, end: start + int(expr.Raw.Length)})
|
|
}
|
|
}
|
|
}
|
|
if err := parser.Error(); err != nil {
|
|
return nil, fmt.Errorf("parse config.toml shell environment expressions: %w", err)
|
|
}
|
|
return removeTOMLByteRanges(content, ranges)
|
|
}
|
|
|
|
func tomlNodeKeys(node *unstable.Node) []string {
|
|
it := node.Key()
|
|
keys := make([]string, 0, 4)
|
|
for it.Next() {
|
|
keys = append(keys, string(it.Node().Data))
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func isCodexShellEnvPolicyPath(keys []string) bool {
|
|
if len(keys) > 0 && keys[0] == shellEnvironmentPolicyKey {
|
|
return true
|
|
}
|
|
return len(keys) >= 3 && keys[0] == "profiles" && keys[2] == shellEnvironmentPolicyKey
|
|
}
|
|
|
|
func tomlTableHeaderLineRange(content []byte, table *unstable.Node) (tomlByteRange, error) {
|
|
it := table.Key()
|
|
if !it.Next() {
|
|
return tomlByteRange{}, errorsForInvalidShellEnvTOML("table header has no key")
|
|
}
|
|
offset := int(it.Node().Raw.Offset)
|
|
if offset < 0 || offset > len(content) {
|
|
return tomlByteRange{}, errorsForInvalidShellEnvTOML("table key range is outside config")
|
|
}
|
|
start := bytes.LastIndexByte(content[:offset], '\n') + 1
|
|
end := len(content)
|
|
if newline := bytes.IndexByte(content[offset:], '\n'); newline >= 0 {
|
|
end = offset + newline + 1
|
|
}
|
|
return tomlByteRange{start: start, end: end}, nil
|
|
}
|
|
|
|
func errorsForInvalidShellEnvTOML(detail string) error {
|
|
return fmt.Errorf("parse config.toml shell environment expressions: %s", detail)
|
|
}
|
|
|
|
func removeTOMLByteRanges(content []byte, ranges []tomlByteRange) ([]byte, error) {
|
|
if len(ranges) == 0 {
|
|
return append([]byte(nil), content...), nil
|
|
}
|
|
sort.Slice(ranges, func(i, j int) bool {
|
|
if ranges[i].start == ranges[j].start {
|
|
return ranges[i].end < ranges[j].end
|
|
}
|
|
return ranges[i].start < ranges[j].start
|
|
})
|
|
|
|
merged := make([]tomlByteRange, 0, len(ranges))
|
|
for _, r := range ranges {
|
|
if r.start < 0 || r.end < r.start || r.end > len(content) {
|
|
return nil, errorsForInvalidShellEnvTOML("expression range is outside config")
|
|
}
|
|
if len(merged) > 0 && r.start <= merged[len(merged)-1].end {
|
|
if r.end > merged[len(merged)-1].end {
|
|
merged[len(merged)-1].end = r.end
|
|
}
|
|
continue
|
|
}
|
|
merged = append(merged, r)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
previous := 0
|
|
for _, r := range merged {
|
|
out.Write(content[previous:r.start])
|
|
previous = r.end
|
|
}
|
|
out.Write(content[previous:])
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// EnsureCodexShellEnvPolicyConfig replaces every user/profile shell policy in
|
|
// the task-local config with one validated daemon-managed policy. The source
|
|
// config in the user's shared Codex home is never modified.
|
|
func EnsureCodexShellEnvPolicyConfig(configPath string, includeOnly []string, logger *slog.Logger) error {
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("read config.toml: %w", err)
|
|
}
|
|
|
|
withoutManaged := []byte(shellEnvBlockRe.ReplaceAllString(string(data), ""))
|
|
stripped, err := stripUserShellEnvPolicy(withoutManaged)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
block, err := renderMulticaShellEnvBlock(includeOnly)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
base := strings.TrimRight(string(stripped), " \t\r\n")
|
|
updated := block
|
|
if base != "" {
|
|
updated = base + "\n\n" + block
|
|
}
|
|
var validated map[string]any
|
|
if err := toml.Unmarshal([]byte(updated), &validated); err != nil {
|
|
return fmt.Errorf("validate generated config.toml shell environment policy: %w", err)
|
|
}
|
|
|
|
if updated == string(data) {
|
|
return nil
|
|
}
|
|
if err := os.WriteFile(configPath, []byte(updated), 0o644); err != nil {
|
|
return fmt.Errorf("write config.toml: %w", err)
|
|
}
|
|
if logger != nil {
|
|
logger.Debug("codex shell environment: wrote managed shell_environment_policy",
|
|
"config_path", configPath,
|
|
"include_count", len(includeOnly),
|
|
)
|
|
}
|
|
return nil
|
|
}
|