1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
//! Subc-mode local config read (subc edge only).
//!
//! Config is single-per-project, read by AFT directly off disk from the
//! CortexKit config files: user `~/.config/cortexkit/aft.jsonc` and project
//! `<root>/.cortexkit/aft.jsonc`. There is NO wire-relayed config path — a front
//! (runner or `mcp:*`) cannot push config over the connection. This makes the
//! resolved config harness-INDEPENDENT: every harness binding a project reads
//! the identical on-disk config, so two trust domains sharing the per-root actor
//! can never diverge or inherit each other's capabilities.
//!
//! Trust is purely per-TIER, applied by `config_resolve` to the FILE tiers: the
//! user file is trusted (the user's own disk), the project file is untrusted
//! (in-repo) and has its privileged fields dropped.
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use crate::config_resolve::ConfigTier;
/// CortexKit user config home: `$XDG_CONFIG_HOME/cortexkit/aft.jsonc`, falling
/// back to `~/.config/cortexkit/aft.jsonc`. Matches the shared CortexKit
/// convention (`~/.config/cortexkit/<module>.jsonc`) alongside `subc.jsonc` and
/// `mcp.jsonc`. Pure over its env inputs so it is testable without mutating
/// process-global env vars (which race under the parallel test runner).
fn user_config_path_from(xdg_config_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
let base = xdg_config_home
.map(PathBuf::from)
// An unset-but-empty `$XDG_CONFIG_HOME` ("") is not absolute → fall back
// to `~/.config`, per the XDG Base Directory spec.
.filter(|p| p.is_absolute())
.or_else(|| home.map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("cortexkit").join("aft.jsonc"))
}
/// Resolve the production CortexKit user config path from the process env. This
/// is the only env-reading entry; it is called once at the subc boundary and the
/// resolved path is threaded down, so the per-bind composition stays pure (and
/// the integration tests inject a path instead of mutating env, which races).
pub fn cortexkit_user_config_path() -> Option<PathBuf> {
let xdg = std::env::var_os("XDG_CONFIG_HOME");
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
user_config_path_from(xdg.as_deref(), home.as_deref())
}
/// CortexKit project config: `<root>/.cortexkit/aft.jsonc`.
fn cortexkit_project_config_path(project_root: &Path) -> PathBuf {
project_root.join(".cortexkit").join("aft.jsonc")
}
/// Read the user + project config files into raw tiers. Pure over its path
/// inputs (no env, no fixed locations) so it is directly testable. Mirrors the
/// TS `readConfigTiers`: push `{tier, source, doc}` with the RAW file content as
/// `doc` (the resolver's `parse_tier` strips JSONC), skipping any missing or
/// unreadable file silently.
fn read_tiers_from(user_config_path: Option<&Path>, project_config_path: &Path) -> Vec<ConfigTier> {
let mut tiers = Vec::new();
if let Some(user_path) = user_config_path {
if let Ok(doc) = std::fs::read_to_string(user_path) {
tiers.push(ConfigTier {
tier: "user".to_string(),
source: user_path.to_string_lossy().into_owned(),
doc,
});
}
}
if let Ok(doc) = std::fs::read_to_string(project_config_path) {
tiers.push(ConfigTier {
tier: "project".to_string(),
source: project_config_path.to_string_lossy().into_owned(),
doc,
});
}
tiers
}
/// Read the CortexKit config home (user) + project config for a subc bind. These
/// tiers are TRUSTED-LOCAL origin and keep their labels. `user_config_path` is
/// resolved once at the subc boundary (`cortexkit_user_config_path`) and passed
/// in, keeping this pure for testing.
pub fn read_local_cortexkit_config_tiers(
user_config_path: Option<&Path>,
project_root: &Path,
) -> Vec<ConfigTier> {
read_tiers_from(
user_config_path,
&cortexkit_project_config_path(project_root),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::config_resolve::resolve_config_onto;
// ---- path resolution (pure, no env mutation) ----
#[test]
fn user_path_prefers_absolute_xdg_config_home() {
// The XDG base must be absolute ON THE HOST OS: `/xdg/cfg` is absolute on
// Unix but NOT on Windows (which needs a drive letter), and the production
// filter correctly ignores a non-absolute XDG per the XDG spec. Build the
// expected path the same way production does so the separator matches.
let xdg = if cfg!(windows) {
r"C:\xdg\cfg"
} else {
"/xdg/cfg"
};
let home = if cfg!(windows) {
r"C:\home\u"
} else {
"/home/u"
};
let path = user_config_path_from(Some(OsStr::new(xdg)), Some(OsStr::new(home)));
let expected = PathBuf::from(xdg).join("cortexkit").join("aft.jsonc");
assert_eq!(path, Some(expected));
}
#[test]
fn user_path_falls_back_to_home_config_when_xdg_unset() {
let path = user_config_path_from(None, Some(OsStr::new("/home/u")));
assert_eq!(
path,
Some(PathBuf::from("/home/u/.config/cortexkit/aft.jsonc"))
);
}
#[test]
fn user_path_treats_empty_xdg_as_unset() {
let path = user_config_path_from(Some(OsStr::new("")), Some(OsStr::new("/home/u")));
assert_eq!(
path,
Some(PathBuf::from("/home/u/.config/cortexkit/aft.jsonc"))
);
}
#[test]
fn user_path_none_when_no_home_and_no_xdg() {
assert_eq!(user_config_path_from(None, None), None);
}
// ---- local file read ----
#[test]
fn reads_user_and_project_with_raw_jsonc_docs() {
let dir = tempfile::tempdir().unwrap();
let user = dir.path().join("user-aft.jsonc");
let project = dir.path().join("project-aft.jsonc");
// Comments preserved in the raw doc — the resolver strips JSONC.
std::fs::write(&user, "{\n // user\n \"search_index\": true\n}").unwrap();
std::fs::write(&project, "{ \"semantic_search\": false }").unwrap();
let tiers = read_tiers_from(Some(&user), &project);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].tier, "user");
assert!(tiers[0].doc.contains("// user"));
assert_eq!(tiers[1].tier, "project");
assert_eq!(tiers[1].source, project.to_string_lossy());
}
#[test]
fn missing_files_yield_no_tiers() {
let dir = tempfile::tempdir().unwrap();
let tiers = read_tiers_from(
Some(&dir.path().join("nope-user.jsonc")),
&dir.path().join("nope-project.jsonc"),
);
assert!(tiers.is_empty());
}
// ---- the security property: per-tier file trust ----
// Config is harness-independent — it comes from the FILES, identical for every
// harness binding a project, so there is nothing for one harness to inject or
// another to inherit. The only trust distinction is per-TIER: the user FILE is
// trusted (the user's own disk), the project FILE is untrusted (in-repo) and
// has its privileged fields dropped by the resolver.
const PRIVILEGED_DOC: &str = r#"{ "semantic": { "api_key_env": "SECRET_KEY" } }"#;
#[test]
fn user_file_privileged_field_is_trusted_project_file_is_dropped() {
let dir = tempfile::tempdir().unwrap();
let user = dir.path().join("user-aft.jsonc");
std::fs::write(&user, PRIVILEGED_DOC).unwrap();
// Project file (in-repo, untrusted) tries to set the same privileged field.
let project_root = dir.path();
let project_cfg_dir = project_root.join(".cortexkit");
std::fs::create_dir_all(&project_cfg_dir).unwrap();
std::fs::write(
project_cfg_dir.join("aft.jsonc"),
r#"{ "semantic": { "api_key_env": "PROJECT_INJECTED" } }"#,
)
.unwrap();
let tiers = read_local_cortexkit_config_tiers(Some(&user), project_root);
let mut base = Config::default();
let dropped = resolve_config_onto(&tiers, &mut base);
// The user FILE's privileged value is honored.
assert_eq!(
base.semantic.api_key_env.as_deref(),
Some("SECRET_KEY"),
"user-file privileged field must be trusted"
);
// The project FILE's attempt to override it is dropped.
assert!(
dropped.iter().any(|d| d.key == "semantic.api_key_env"),
"project-file privileged field must be dropped by the resolver"
);
}
}