@lamentis/naome 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/Cargo.lock +2 -2
  2. package/README.md +5 -0
  3. package/crates/naome-cli/Cargo.toml +1 -1
  4. package/crates/naome-cli/src/install_bridge.rs +56 -8
  5. package/crates/naome-core/Cargo.toml +1 -1
  6. package/crates/naome-core/src/context/select.rs +58 -4
  7. package/crates/naome-core/src/harness_health/integrity.rs +41 -23
  8. package/crates/naome-core/src/harness_health/manifest.rs +97 -0
  9. package/crates/naome-core/src/harness_health.rs +58 -106
  10. package/crates/naome-core/src/intent/classifier.rs +56 -81
  11. package/crates/naome-core/src/intent/envelope.rs +173 -19
  12. package/crates/naome-core/src/intent/legacy_response.rs +2 -0
  13. package/crates/naome-core/src/intent/model.rs +6 -0
  14. package/crates/naome-core/src/intent/resolver.rs +25 -0
  15. package/crates/naome-core/src/intent/risk.rs +11 -1
  16. package/crates/naome-core/src/intent.rs +1 -1
  17. package/crates/naome-core/src/quality/cache.rs +122 -19
  18. package/crates/naome-core/src/quality/scanner/analysis.rs +4 -2
  19. package/crates/naome-core/src/quality/scanner/repo_paths.rs +27 -3
  20. package/crates/naome-core/src/quality/scanner.rs +5 -2
  21. package/crates/naome-core/src/route/context.rs +8 -0
  22. package/crates/naome-core/src/workflow/integrity_support.rs +10 -3
  23. package/crates/naome-core/tests/context.rs +92 -0
  24. package/crates/naome-core/tests/harness_health.rs +149 -0
  25. package/crates/naome-core/tests/intent.rs +98 -18
  26. package/crates/naome-core/tests/intent_support/mod.rs +39 -1
  27. package/crates/naome-core/tests/intent_v2.rs +299 -10
  28. package/crates/naome-core/tests/quality_performance.rs +63 -2
  29. package/crates/naome-core/tests/repo_support/routes.rs +8 -2
  30. package/crates/naome-core/tests/route_baseline.rs +29 -0
  31. package/crates/naome-core/tests/route_completion.rs +26 -5
  32. package/crates/naome-core/tests/route_harness_refresh.rs +7 -1
  33. package/crates/naome-core/tests/route_user_diff.rs +1 -1
  34. package/crates/naome-core/tests/task_state_compact.rs +7 -1
  35. package/installer/filesystem.js +38 -0
  36. package/installer/flows.js +6 -1
  37. package/installer/harness-file-ops.js +36 -8
  38. package/installer/manifest-state.js +2 -2
  39. package/installer/native.js +63 -18
  40. package/native/darwin-arm64/naome +0 -0
  41. package/native/linux-x64/naome +0 -0
  42. package/package.json +1 -1
  43. package/templates/naome-root/.naome/bin/check-harness-health.js +25 -21
  44. package/templates/naome-root/.naome/bin/check-task-state.js +35 -42
  45. package/templates/naome-root/.naome/manifest.json +10 -10
  46. package/templates/naome-root/docs/naome/agent-workflow.md +14 -5
  47. package/templates/naome-root/docs/naome/architecture.md +9 -0
  48. package/crates/naome-core/src/intent/patterns.rs +0 -170
@@ -1,5 +1,6 @@
1
- use std::fs;
2
- use std::path::{Path, PathBuf};
1
+ use std::fs::{self, OpenOptions};
2
+ use std::io::Write;
3
+ use std::path::{Component, Path, PathBuf};
3
4
 
4
5
  use serde::{Deserialize, Serialize};
5
6
  use sha2::{Digest, Sha256};
@@ -49,7 +50,11 @@ impl QualityCache {
49
50
  }
50
51
 
51
52
  pub(crate) fn read(&self, path: &str, content_hash: &str) -> Option<FileAnalysis> {
52
- let entry = fs::read_to_string(self.entry_path(path, content_hash)).ok()?;
53
+ let cache_path = self.safe_entry_path(path, content_hash).ok()?;
54
+ if !regular_file_without_symlink(&cache_path) {
55
+ return None;
56
+ }
57
+ let entry = fs::read_to_string(cache_path).ok()?;
53
58
  let entry: CacheEntry = serde_json::from_str(&entry).ok()?;
54
59
  if entry.schema == CACHE_SCHEMA
55
60
  && entry.naome_version == env!("CARGO_PKG_VERSION")
@@ -70,10 +75,7 @@ impl QualityCache {
70
75
  content_hash: &str,
71
76
  analysis: &FileAnalysis,
72
77
  ) -> Result<(), NaomeError> {
73
- let cache_path = self.entry_path(path, content_hash);
74
- if let Some(parent) = cache_path.parent() {
75
- fs::create_dir_all(parent)?;
76
- }
78
+ let cache_path = self.safe_entry_path(path, content_hash)?;
77
79
  let entry = CacheEntry {
78
80
  schema: CACHE_SCHEMA.to_string(),
79
81
  naome_version: env!("CARGO_PKG_VERSION").to_string(),
@@ -84,31 +86,29 @@ impl QualityCache {
84
86
  analysis: analysis.clone(),
85
87
  };
86
88
  let content = format!("{}\n", serde_json::to_string(&entry)?);
87
- if fs::read_to_string(&cache_path).map_or(true, |current| current != content) {
88
- fs::write(cache_path, content)?;
89
- }
90
- Ok(())
89
+ write_cache_entry(&cache_path, &content)
91
90
  }
92
91
 
93
- fn entry_path(&self, path: &str, content_hash: &str) -> PathBuf {
94
- self.root.join(CACHE_RELATIVE_PATH).join(stable_key(&[
92
+ fn safe_entry_path(&self, path: &str, content_hash: &str) -> Result<PathBuf, NaomeError> {
93
+ let cache_root = safe_cache_root(&self.root, true)?;
94
+ Ok(cache_root.join(stable_key(&[
95
95
  env!("CARGO_PKG_VERSION"),
96
96
  &self.config_hash,
97
97
  ADAPTER_VERSION,
98
98
  path,
99
99
  content_hash,
100
- ]))
100
+ ])))
101
101
  }
102
102
  }
103
103
 
104
104
  pub fn quality_cache_status(root: &Path) -> Result<QualityCacheStatus, NaomeError> {
105
- let path = root.join(CACHE_RELATIVE_PATH);
105
+ let path = safe_cache_root(root, false)?;
106
106
  let mut entry_count = 0;
107
107
  let mut bytes = 0;
108
- if path.is_dir() {
108
+ if regular_directory_without_symlink(&path) {
109
109
  for entry in fs::read_dir(&path)? {
110
110
  let entry = entry?;
111
- let metadata = entry.metadata()?;
111
+ let metadata = fs::symlink_metadata(entry.path())?;
112
112
  if metadata.is_file() {
113
113
  entry_count += 1;
114
114
  bytes += metadata.len();
@@ -124,13 +124,116 @@ pub fn quality_cache_status(root: &Path) -> Result<QualityCacheStatus, NaomeErro
124
124
  }
125
125
 
126
126
  pub fn clear_quality_cache(root: &Path) -> Result<QualityCacheStatus, NaomeError> {
127
- let path = root.join(CACHE_RELATIVE_PATH);
128
- if path.exists() {
127
+ let path = safe_cache_root(root, false)?;
128
+ if regular_directory_without_symlink(&path) {
129
129
  fs::remove_dir_all(&path)?;
130
130
  }
131
131
  quality_cache_status(root)
132
132
  }
133
133
 
134
+ fn write_cache_entry(cache_path: &Path, content: &str) -> Result<(), NaomeError> {
135
+ validate_cache_entry_parent(cache_path)?;
136
+ match fs::symlink_metadata(cache_path) {
137
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
138
+ return Err(NaomeError::new(format!(
139
+ "quality cache entry must be a regular file: {}",
140
+ cache_path.display()
141
+ )));
142
+ }
143
+ Ok(_) => {
144
+ if fs::read_to_string(cache_path).map_or(false, |current| current == content) {
145
+ return Ok(());
146
+ }
147
+ }
148
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
149
+ Err(error) => return Err(error.into()),
150
+ }
151
+
152
+ let temp_path = cache_path.with_extension(format!(
153
+ "tmp.{}.{}",
154
+ std::process::id(),
155
+ stable_key(&[content]).trim_end_matches(".json")
156
+ ));
157
+ validate_cache_entry_parent(&temp_path)?;
158
+ let mut temp = OpenOptions::new()
159
+ .write(true)
160
+ .create_new(true)
161
+ .open(&temp_path)?;
162
+ if let Err(error) = temp.write_all(content.as_bytes()) {
163
+ let _ = fs::remove_file(&temp_path);
164
+ return Err(error.into());
165
+ }
166
+ if let Err(error) = temp.sync_all() {
167
+ let _ = fs::remove_file(&temp_path);
168
+ return Err(error.into());
169
+ }
170
+ drop(temp);
171
+ validate_cache_entry_parent(cache_path)?;
172
+ if let Err(error) = fs::rename(&temp_path, cache_path) {
173
+ let _ = fs::remove_file(&temp_path);
174
+ return Err(error.into());
175
+ }
176
+ Ok(())
177
+ }
178
+
179
+ fn validate_cache_entry_parent(cache_path: &Path) -> Result<(), NaomeError> {
180
+ let Some(parent) = cache_path.parent() else {
181
+ return Err(NaomeError::new("quality cache entry path has no parent"));
182
+ };
183
+ if !regular_directory_without_symlink(parent) {
184
+ return Err(NaomeError::new(format!(
185
+ "quality cache path must be a regular directory without symlinks: {}",
186
+ parent.display()
187
+ )));
188
+ }
189
+ Ok(())
190
+ }
191
+
192
+ fn safe_cache_root(root: &Path, create: bool) -> Result<PathBuf, NaomeError> {
193
+ let mut current = root.to_path_buf();
194
+ for component in Path::new(CACHE_RELATIVE_PATH).components() {
195
+ let Component::Normal(component) = component else {
196
+ return Err(NaomeError::new(
197
+ "quality cache path must be repository-relative",
198
+ ));
199
+ };
200
+ current.push(component);
201
+ match fs::symlink_metadata(&current) {
202
+ Ok(metadata) if metadata.file_type().is_symlink() => {
203
+ return Err(NaomeError::new(format!(
204
+ "quality cache path must not contain symlinks: {}",
205
+ current.display()
206
+ )));
207
+ }
208
+ Ok(metadata) if metadata.is_dir() => {}
209
+ Ok(_) => {
210
+ return Err(NaomeError::new(format!(
211
+ "quality cache path component is not a directory: {}",
212
+ current.display()
213
+ )));
214
+ }
215
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound && create => {
216
+ fs::create_dir(&current)?;
217
+ }
218
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current),
219
+ Err(error) => return Err(error.into()),
220
+ }
221
+ }
222
+ Ok(current)
223
+ }
224
+
225
+ fn regular_directory_without_symlink(path: &Path) -> bool {
226
+ fs::symlink_metadata(path)
227
+ .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
228
+ .unwrap_or(false)
229
+ }
230
+
231
+ fn regular_file_without_symlink(path: &Path) -> bool {
232
+ fs::symlink_metadata(path)
233
+ .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
234
+ .unwrap_or(false)
235
+ }
236
+
134
237
  pub(crate) fn content_hash(content: &str) -> String {
135
238
  stable_key(&[content])
136
239
  }
@@ -8,6 +8,8 @@ use super::{FileAnalysis, NormalizedLine, SymbolAnalysis};
8
8
  use crate::quality::cache::{content_hash, QualityCache};
9
9
  use normalize::{normalize_line, token_set};
10
10
 
11
+ use super::repo_paths::regular_repo_file_path;
12
+
11
13
  pub(super) fn analyze_repo_file(
12
14
  root: &Path,
13
15
  path: &str,
@@ -16,8 +18,8 @@ pub(super) fn analyze_repo_file(
16
18
  cache: &QualityCache,
17
19
  allow_cache: bool,
18
20
  ) -> Option<(FileAnalysis, bool)> {
19
- let full_path = root.join(path);
20
- if !full_path.is_file() || is_binary_extension(path) {
21
+ let full_path = regular_repo_file_path(root, path)?;
22
+ if is_binary_extension(path) {
21
23
  return None;
22
24
  }
23
25
  if allow_cache {
@@ -1,6 +1,6 @@
1
1
  use std::collections::{HashMap, HashSet};
2
2
  use std::fs;
3
- use std::path::Path;
3
+ use std::path::{Component, Path};
4
4
  use std::process::Command;
5
5
 
6
6
  use crate::models::NaomeError;
@@ -44,8 +44,10 @@ pub(super) fn added_lines_by_path(
44
44
  if !target_paths.contains(&path) {
45
45
  continue;
46
46
  }
47
- if let Ok(content) = fs::read_to_string(root.join(&path)) {
48
- added.insert(path, content.lines().count());
47
+ if let Some(file_path) = regular_repo_file_path(root, &path) {
48
+ if let Ok(content) = fs::read_to_string(file_path) {
49
+ added.insert(path, content.lines().count());
50
+ }
49
51
  }
50
52
  }
51
53
  Ok(added)
@@ -92,3 +94,25 @@ fn untracked_paths(root: &Path) -> Result<Vec<String>, NaomeError> {
92
94
  .map(|entry| String::from_utf8_lossy(entry).replace('\\', "/"))
93
95
  .collect())
94
96
  }
97
+
98
+ pub(super) fn regular_repo_file_path(root: &Path, path: &str) -> Option<std::path::PathBuf> {
99
+ let relative = Path::new(path);
100
+ if relative.is_absolute() {
101
+ return None;
102
+ }
103
+ let mut current = root.to_path_buf();
104
+ for component in relative.components() {
105
+ let Component::Normal(component) = component else {
106
+ return None;
107
+ };
108
+ current.push(component);
109
+ let metadata = fs::symlink_metadata(&current).ok()?;
110
+ if metadata.file_type().is_symlink() {
111
+ return None;
112
+ }
113
+ }
114
+ fs::symlink_metadata(&current)
115
+ .ok()
116
+ .filter(|metadata| metadata.is_file())
117
+ .map(|_| current)
118
+ }
@@ -9,7 +9,7 @@ use sha2::{Digest, Sha256};
9
9
 
10
10
  use crate::{git, models::NaomeError, paths};
11
11
  pub(crate) use repo_paths::collect_repo_paths;
12
- use repo_paths::{added_lines_by_path, tracked_blob_hashes};
12
+ use repo_paths::{added_lines_by_path, regular_repo_file_path, tracked_blob_hashes};
13
13
 
14
14
  use super::cache::QualityCache;
15
15
  use super::types::{
@@ -317,7 +317,10 @@ fn max_file_bytes(mode: QualityMode) -> u64 {
317
317
  }
318
318
 
319
319
  fn file_exceeds_budget(root: &Path, path: &str, mode: QualityMode) -> bool {
320
- fs::metadata(root.join(path)).map_or(false, |metadata| {
320
+ let Some(full_path) = regular_repo_file_path(root, path) else {
321
+ return false;
322
+ };
323
+ fs::symlink_metadata(full_path).map_or(false, |metadata| {
321
324
  metadata.is_file() && metadata.len() > max_file_bytes(mode)
322
325
  })
323
326
  }
@@ -57,6 +57,8 @@ pub(super) fn winning_rule(intent: &IntentDecision) -> String {
57
57
  "current_task_revision_continues_task"
58
58
  }
59
59
  "answer_status_only" => "status_request_read_only",
60
+ "normalize_prompt_first" => "prompt_envelope_required_before_routing",
61
+ "answer_without_mutation" => "advisory_prompt_read_only",
60
62
  "create_new_task" | "create_new_task_without_auto_baseline" => "ready_repo_new_task",
61
63
  "create_isolated_task_worktree" => "dirty_repo_new_task_worktree_isolation",
62
64
  "commit_user_diff_with_quality_gate" => "explicit_user_diff_commit_quality_gate",
@@ -164,6 +166,12 @@ pub(super) fn required_context_for_intent(intent: &IntentDecision) -> Vec<String
164
166
  "answer_status_only" => {
165
167
  push_unique(&mut context, "docs/naome/index.md");
166
168
  }
169
+ "normalize_prompt_first" => {
170
+ push_unique(&mut context, "docs/naome/agent-workflow.md");
171
+ }
172
+ "answer_without_mutation" => {
173
+ push_unique(&mut context, "docs/naome/index.md");
174
+ }
167
175
  "repair_harness_only" => {
168
176
  push_unique(&mut context, ".naome/manifest.json");
169
177
  push_unique(&mut context, "docs/naome/index.md");
@@ -23,8 +23,14 @@ pub(super) fn refresh_support_files(
23
23
  changed.push(path.to_string());
24
24
  }
25
25
  }
26
- if replace_native_integrity(root, integrity)? {
27
- changed.push(NAOME_COMMAND_PATH.to_string());
26
+ for path in [
27
+ NAOME_COMMAND_PATH,
28
+ HEALTH_CHECKER_PATH,
29
+ TASK_STATE_CHECKER_PATH,
30
+ ] {
31
+ if replace_native_integrity(root, path, integrity)? {
32
+ changed.push(path.to_string());
33
+ }
28
34
  }
29
35
  Ok(changed)
30
36
  }
@@ -76,12 +82,13 @@ fn render_expected_integrity_block(integrity: &BTreeMap<String, String>) -> Stri
76
82
 
77
83
  fn replace_native_integrity(
78
84
  root: &Path,
85
+ relative_path: &str,
79
86
  integrity: &BTreeMap<String, String>,
80
87
  ) -> Result<bool, NaomeError> {
81
88
  let Some(native_hash) = integrity.get(NATIVE_BINARY_PATH) else {
82
89
  return Ok(false);
83
90
  };
84
- let path = root.join(NAOME_COMMAND_PATH);
91
+ let path = root.join(relative_path);
85
92
  if !path.is_file() {
86
93
  return Ok(false);
87
94
  }
@@ -66,6 +66,91 @@ fn prompt_context_uses_file_mentions_without_broad_markdown_context() {
66
66
  .any(|item| item.path == "docs/naome/repository-quality.md"));
67
67
  }
68
68
 
69
+ #[test]
70
+ fn prompt_context_ignores_nonexistent_concept_tokens_that_look_path_like() {
71
+ let repo = context_repo("context-prompt-concept-terms");
72
+ repo.write_file("docs/naome/repository-quality.md", "# Quality\n");
73
+ repo.commit_all("baseline");
74
+
75
+ let selection = select_context_for_prompt(
76
+ repo.path(),
77
+ "Advisory/planning-only prompts and German/English examples must not become paths.",
78
+ )
79
+ .unwrap();
80
+
81
+ assert_eq!(selection.mode, "prompt");
82
+ assert_eq!(
83
+ selection.required_context[0].path,
84
+ ".naome/verification.json"
85
+ );
86
+ assert!(!selection
87
+ .required_context
88
+ .iter()
89
+ .any(|item| item.path == "Advisory/planning-only" || item.path == "German/English"));
90
+ }
91
+
92
+ #[test]
93
+ fn prompt_context_prefers_envelope_referenced_paths_over_raw_prompt_tokens() {
94
+ let repo = context_repo("context-prompt-envelope-paths");
95
+ repo.write_file("packages/app/src/lib.rs", "pub fn app() {}\n");
96
+ repo.write_file("packages/app/src/other.rs", "pub fn other() {}\n");
97
+ repo.commit_all("baseline");
98
+
99
+ let selection = select_context_for_prompt(
100
+ repo.path(),
101
+ "```naome-prompt-envelope-v1\n{\"schema\":\"naome.prompt-envelope.v1\",\"requestKind\":\"implementation\",\"mutationIntent\":\"modify_files\",\"workflowAction\":\"none\",\"taskIntent\":\"new_task\",\"risk\":\"none\",\"requestedActions\":[],\"referencedPaths\":[\"packages/app/src/lib.rs\"],\"constraints\":[],\"uncertainties\":[]}\n```\n\nPlease mention packages/app/src/other.rs in prose but use the envelope path.",
102
+ )
103
+ .unwrap();
104
+
105
+ assert_eq!(selection.mode, "prompt");
106
+ assert_eq!(
107
+ selection.required_context[0].path,
108
+ "packages/app/src/lib.rs"
109
+ );
110
+ assert!(!selection
111
+ .required_context
112
+ .iter()
113
+ .any(|item| item.path == "packages/app/src/other.rs"));
114
+ }
115
+
116
+ #[test]
117
+ fn prompt_context_keeps_envelope_paths_for_nested_creation_targets() {
118
+ let repo = app_context_repo("context-prompt-envelope-new-paths");
119
+
120
+ let selection = select_context_for_prompt(
121
+ repo.path(),
122
+ "```naome-prompt-envelope-v1\n{\"schema\":\"naome.prompt-envelope.v1\",\"requestKind\":\"implementation\",\"mutationIntent\":\"create_files\",\"workflowAction\":\"none\",\"taskIntent\":\"new_task\",\"risk\":\"none\",\"requestedActions\":[],\"referencedPaths\":[\"packages/app/src/new/mod.rs\"],\"constraints\":[],\"uncertainties\":[]}\n```\n\nCreate the new module.",
123
+ )
124
+ .unwrap();
125
+
126
+ assert_eq!(selection.mode, "prompt");
127
+ assert_eq!(
128
+ selection.required_context[0].path,
129
+ "packages/app/src/new/mod.rs"
130
+ );
131
+ }
132
+
133
+ #[test]
134
+ fn prompt_context_rejects_envelope_paths_outside_repository() {
135
+ let repo = app_context_repo("context-prompt-envelope-safe-paths");
136
+
137
+ let selection = select_context_for_prompt(
138
+ repo.path(),
139
+ "```naome-prompt-envelope-v1\n{\"schema\":\"naome.prompt-envelope.v1\",\"requestKind\":\"implementation\",\"mutationIntent\":\"modify_files\",\"workflowAction\":\"none\",\"taskIntent\":\"new_task\",\"risk\":\"none\",\"requestedActions\":[],\"referencedPaths\":[\"../notes.md\",\"/tmp/file.rs\",\"packages/app/src/lib.rs\"],\"constraints\":[],\"uncertainties\":[]}\n```",
140
+ )
141
+ .unwrap();
142
+
143
+ assert_eq!(selection.mode, "prompt");
144
+ assert_eq!(
145
+ selection.required_context[0].path,
146
+ "packages/app/src/lib.rs"
147
+ );
148
+ assert!(!selection
149
+ .required_context
150
+ .iter()
151
+ .any(|item| item.path == "../notes.md" || item.path == "/tmp/file.rs"));
152
+ }
153
+
69
154
  #[test]
70
155
  fn context_selection_reports_over_budget_when_many_paths_change() {
71
156
  let repo = context_repo("context-budget");
@@ -97,3 +182,10 @@ fn context_repo(name: &str) -> TestRepo {
97
182
  );
98
183
  repo
99
184
  }
185
+
186
+ fn app_context_repo(name: &str) -> TestRepo {
187
+ let repo = context_repo(name);
188
+ repo.write_file("packages/app/src/lib.rs", "pub fn app() {}\n");
189
+ repo.commit_all("baseline");
190
+ repo
191
+ }
@@ -24,6 +24,11 @@ const MACHINE_OWNED_PATHS: &[&str] = &[
24
24
  "docs/naome/upgrade.md",
25
25
  ];
26
26
 
27
+ #[cfg(windows)]
28
+ const NATIVE_BINARY_PATH: &str = ".naome/bin/naome-rust.exe";
29
+ #[cfg(not(windows))]
30
+ const NATIVE_BINARY_PATH: &str = ".naome/bin/naome-rust";
31
+
27
32
  const PROJECT_OWNED_PATHS: &[&str] = &[
28
33
  ".naomeignore",
29
34
  ".naome/init-state.json",
@@ -78,6 +83,99 @@ fn rejects_drifted_machine_owned_files() {
78
83
  assert!(joined.contains("docs/naome/execution.md"));
79
84
  }
80
85
 
86
+ #[test]
87
+ fn accepts_native_decision_binary_with_manifest_ownership_and_integrity() {
88
+ let mut repo = HarnessFixture::new();
89
+ repo.install_native_decision_binary("native decision fixture\n");
90
+
91
+ let errors = validate_harness_health(repo.path(), options(repo.integrity.clone())).unwrap();
92
+
93
+ assert!(errors.is_empty(), "{errors:#?}");
94
+ }
95
+
96
+ #[test]
97
+ fn rejects_native_decision_binary_when_manifest_ownership_is_removed() {
98
+ let mut repo = HarnessFixture::new();
99
+ repo.install_native_decision_binary("native decision fixture\n");
100
+ repo.remove_native_manifest_entry();
101
+
102
+ let errors = validate_harness_health(repo.path(), options(repo.integrity.clone())).unwrap();
103
+ let joined = errors.join("\n");
104
+
105
+ assert!(
106
+ joined.contains(&format!(
107
+ ".naome/manifest.json machineOwned must include {NATIVE_BINARY_PATH}."
108
+ )),
109
+ "{joined}"
110
+ );
111
+ assert!(
112
+ joined.contains(&format!(
113
+ ".naome/manifest.json integrity missing {NATIVE_BINARY_PATH}."
114
+ )),
115
+ "{joined}"
116
+ );
117
+ }
118
+
119
+ #[test]
120
+ fn rejects_checker_declared_native_integrity_without_native_manifest_entry() {
121
+ let mut repo = HarnessFixture::new();
122
+ let checker_path = ".naome/bin/check-task-state.js";
123
+ let native_hash = format!("sha256:{}", "a".repeat(64));
124
+ repo.write(
125
+ checker_path,
126
+ &format!("const expectedNativeBinaryIntegrity = \"{native_hash}\";\n"),
127
+ );
128
+ repo.integrity.insert(
129
+ checker_path.to_string(),
130
+ format!(
131
+ "sha256:{}",
132
+ sha256("const expectedNativeBinaryIntegrity = \"sha256:generated\";\n")
133
+ ),
134
+ );
135
+ write_file(
136
+ repo.path(),
137
+ ".naome/manifest.json",
138
+ &pretty_json(manifest_fixture(&repo.integrity)),
139
+ );
140
+
141
+ let errors = validate_harness_health(repo.path(), options(repo.integrity.clone())).unwrap();
142
+ let joined = errors.join("\n");
143
+
144
+ assert!(
145
+ joined.contains(&format!(
146
+ ".naome/manifest.json machineOwned must include {NATIVE_BINARY_PATH}."
147
+ )),
148
+ "{joined}"
149
+ );
150
+ assert!(
151
+ joined.contains(&format!("{NATIVE_BINARY_PATH} is missing.")),
152
+ "{joined}"
153
+ );
154
+ }
155
+
156
+ #[test]
157
+ fn rejects_native_integrity_assignment_with_appended_code() {
158
+ let mut repo = HarnessFixture::new();
159
+ repo.install_native_decision_binary("native decision fixture\n");
160
+ let native_hash = repo.integrity.get(NATIVE_BINARY_PATH).unwrap().clone();
161
+ repo.write(
162
+ ".naome/bin/check-harness-health.js",
163
+ &format!("const expectedNativeBinaryIntegrity = \"{native_hash}\"; process.exit(0);\n"),
164
+ );
165
+
166
+ let errors = validate_harness_health(repo.path(), options(repo.integrity.clone())).unwrap();
167
+ let joined = errors.join("\n");
168
+
169
+ assert!(
170
+ joined.contains(".naome/bin/check-harness-health.js integrity mismatch"),
171
+ "{joined}"
172
+ );
173
+ assert!(
174
+ joined.contains(".naome/bin/check-harness-health.js native binary integrity does not match .naome/manifest.json."),
175
+ "{joined}"
176
+ );
177
+ }
178
+
81
179
  #[test]
82
180
  fn rejects_missing_archive_ignore_boundary() {
83
181
  let repo = HarnessFixture::new();
@@ -175,6 +273,53 @@ impl HarnessFixture {
175
273
  fn write(&self, relative_path: &str, content: &str) {
176
274
  write_file(&self.root, relative_path, content);
177
275
  }
276
+
277
+ fn install_native_decision_binary(&mut self, content: &str) {
278
+ let native_hash = format!("sha256:{}", sha256(content));
279
+ write_file(&self.root, NATIVE_BINARY_PATH, content);
280
+ self.integrity
281
+ .insert(NATIVE_BINARY_PATH.to_string(), native_hash.clone());
282
+
283
+ for relative_path in [
284
+ ".naome/bin/naome.js",
285
+ ".naome/bin/check-harness-health.js",
286
+ ".naome/bin/check-task-state.js",
287
+ ] {
288
+ let command_content =
289
+ format!("const expectedNativeBinaryIntegrity = \"{native_hash}\";\n");
290
+ let normalized_command_content =
291
+ "const expectedNativeBinaryIntegrity = \"sha256:generated\";\n";
292
+ write_file(&self.root, relative_path, &command_content);
293
+ self.integrity.insert(
294
+ relative_path.to_string(),
295
+ format!("sha256:{}", sha256(normalized_command_content)),
296
+ );
297
+ }
298
+
299
+ self.write_manifest_with_native_entry();
300
+ }
301
+
302
+ fn remove_native_manifest_entry(&self) {
303
+ let mut manifest = read_json_value(&self.root, ".naome/manifest.json");
304
+ manifest["machineOwned"]
305
+ .as_array_mut()
306
+ .unwrap()
307
+ .retain(|entry| entry.as_str() != Some(NATIVE_BINARY_PATH));
308
+ manifest["integrity"]
309
+ .as_object_mut()
310
+ .unwrap()
311
+ .remove(NATIVE_BINARY_PATH);
312
+ write_file(&self.root, ".naome/manifest.json", &pretty_json(manifest));
313
+ }
314
+
315
+ fn write_manifest_with_native_entry(&self) {
316
+ let mut manifest = manifest_fixture(&self.integrity);
317
+ manifest["machineOwned"]
318
+ .as_array_mut()
319
+ .unwrap()
320
+ .push(json!(NATIVE_BINARY_PATH));
321
+ write_file(&self.root, ".naome/manifest.json", &pretty_json(manifest));
322
+ }
178
323
  }
179
324
 
180
325
  fn manifest_fixture(integrity: &HashMap<String, String>) -> serde_json::Value {
@@ -217,6 +362,10 @@ fn machine_content(relative_path: &str) -> String {
217
362
  }
218
363
  }
219
364
 
365
+ fn read_json_value(root: &Path, relative_path: &str) -> serde_json::Value {
366
+ serde_json::from_str(&fs::read_to_string(root.join(relative_path)).unwrap()).unwrap()
367
+ }
368
+
220
369
  fn write_file(root: &Path, relative_path: &str, content: &str) {
221
370
  let path = root.join(relative_path);
222
371
  fs::create_dir_all(path.parent().unwrap()).unwrap();