Bootstrap returns the config for the current project, creating .codehamr/ and config.yaml on first use. config.yaml is never overwritten; the prompt is embedded, never written to disk. The directory check uses Lstat (not Stat) and refuses a pre-existing .codehamr that isn't a real directory: a syml
(projectRoot string)
| 116 | // .codehamr that isn't a real directory: a symlink there would let a co-tenant |
| 117 | // redirect config.yaml to an attacker path, planting a models.<name>.url that |
| 118 | // proxies the hamrpass key on the next dial-out. |
| 119 | func Bootstrap(projectRoot string) (*Config, bool, error) { |
| 120 | dir := filepath.Join(projectRoot, DirName) |
| 121 | created := false |
| 122 | info, err := os.Lstat(dir) |
| 123 | switch { |
| 124 | case err == nil: |
| 125 | if info.Mode()&os.ModeSymlink != 0 { |
| 126 | return nil, false, fmt.Errorf("%s: refuses to follow symlink, remove or replace with a real directory", dir) |
| 127 | } |
| 128 | if !info.IsDir() { |
| 129 | return nil, false, fmt.Errorf("%s: exists but is not a directory", dir) |
| 130 | } |
| 131 | // Tighten a pre-existing loose dir (created by an older release or by |
| 132 | // hand): same upgrade-path rationale as Save's fresh-temp-inode trick |
| 133 | // for config.yaml, applied to the directory the threat comment below |
| 134 | // is about. Best-effort; a failure here shouldn't block launch. |
| 135 | if info.Mode().Perm() != 0o700 { |
| 136 | _ = os.Chmod(dir, 0o700) |
| 137 | } |
| 138 | case errors.Is(err, os.ErrNotExist): |
| 139 | // 0o700: config.yaml may carry the hamrpass key (a long-lived bearer |
| 140 | // token). A world-listable dir lets other local users spot it and probe |
| 141 | // for the key. Only the project owner should read here. |
| 142 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 143 | return nil, false, err |
| 144 | } |
| 145 | created = true |
| 146 | default: |
| 147 | return nil, false, err |
| 148 | } |
| 149 | |
| 150 | cfgPath := filepath.Join(dir, "config.yaml") |
| 151 | // Same symlink defence as the directory check: a symlinked config.yaml |
| 152 | // could redirect the read (which config we honour) or the write (clobbering |
| 153 | // an arbitrary user-writable file with the seed). Refuse with a clear error. |
| 154 | if li, err := os.Lstat(cfgPath); err == nil && li.Mode()&os.ModeSymlink != 0 { |
| 155 | return nil, false, fmt.Errorf("%s: refuses to follow symlink, remove or replace with a real file", cfgPath) |
| 156 | } |
| 157 | var cfg *Config |
| 158 | if b, err := os.ReadFile(cfgPath); err == nil { |
| 159 | cfg = &Config{} // do NOT merge Default here; strict means strict |
| 160 | dec := yaml.NewDecoder(bytes.NewReader(b)) |
| 161 | dec.KnownFields(true) |
| 162 | if err := dec.Decode(cfg); err != nil { |
| 163 | return nil, false, fmt.Errorf("config.yaml: %w", err) |
| 164 | } |
| 165 | } else if errors.Is(err, os.ErrNotExist) { |
| 166 | cfg = Default() |
| 167 | if err := writeYAML(cfgPath, cfg); err != nil { |
| 168 | return nil, false, err |
| 169 | } |
| 170 | } else { |
| 171 | return nil, false, err |
| 172 | } |
| 173 | cfg.Dir = dir |
| 174 | |
| 175 | // YAML `models: { name: ~ }` decodes to a nil *Profile that would panic on |