Skip to content

Commit 3735aa4

Browse files
Show folders on approval cards; warn when memory is short now
- Redaction's high-entropy rule masked whole absolute paths such as /Volumes/NVME1TB/shadowcode-macos/task-demo (long, mixed case, digits and '/', like base64), so a command's folder showed as "[redacted secret]" on its approval card; on macOS that is most project paths. A path under a well-known root (/Users, /Volumes, /private, /home, ..., also after file://) is now judged one segment at a time: a secret that is itself a segment is still hidden, and base64 that only contains '/' is judged whole as before. - Whether a local model fits was judged from installed memory only. The engine now also reads memory free right now (MemAvailable on Linux; free, inactive, speculative and purgeable pages on macOS), and a model that runs in RAM, or in Apple silicon's shared memory, says "Only N GB of memory is free right now; close other apps first or it will run very slowly" when it would not fit. Seen on an 8 GB iMac whose iOS Simulator held most of its memory: Granite 3B ran at 6 s per token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6703a37 commit 3735aa4

3 files changed

Lines changed: 210 additions & 23 deletions

File tree

‎native/core/src/local_engine.rs‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,54 @@ pub fn runtime_from(candidates: Vec<(PathBuf, &'static str)>) -> Runtime {
816816
first_failure.expect("at least one candidate")
817817
}
818818

819+
/// Memory available to a new process now, in bytes (0 when unknown):
820+
/// Linux's `MemAvailable`; on macOS free, inactive, speculative and
821+
/// purgeable pages (what Activity Monitor does not count as used).
822+
#[cfg(target_os = "macos")]
823+
pub fn available_memory() -> u64 {
824+
extern "C" {
825+
// libSystem's own; libc's binding is deprecated in favour of a crate.
826+
fn mach_host_self() -> libc::mach_port_t;
827+
}
828+
// One send right to the host port for the life of the process.
829+
static HOST: std::sync::OnceLock<libc::mach_port_t> = std::sync::OnceLock::new();
830+
// SAFETY: mach_host_self has no preconditions.
831+
let host = *HOST.get_or_init(|| unsafe { mach_host_self() });
832+
let mut info: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
833+
let mut count = libc::HOST_VM_INFO64_COUNT;
834+
// SAFETY: host_statistics64 writes at most `count` integers into `info`.
835+
let result = unsafe {
836+
libc::host_statistics64(
837+
host,
838+
libc::HOST_VM_INFO64,
839+
(&mut info as *mut libc::vm_statistics64).cast(),
840+
&mut count,
841+
)
842+
};
843+
// SAFETY: sysconf has no preconditions.
844+
let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
845+
if result != 0 || page <= 0 {
846+
return 0;
847+
}
848+
let pages = u64::from(info.free_count)
849+
+ u64::from(info.inactive_count)
850+
+ u64::from(info.speculative_count)
851+
+ u64::from(info.purgeable_count);
852+
pages.saturating_mul(page as u64)
853+
}
854+
855+
/// See the macOS version.
856+
#[cfg(not(target_os = "macos"))]
857+
pub fn available_memory() -> u64 {
858+
let Ok(text) = fs::read_to_string("/proc/meminfo") else {
859+
return 0;
860+
};
861+
text.lines()
862+
.find_map(|line| line.strip_prefix("MemAvailable:"))
863+
.and_then(|rest| rest.split_whitespace().next()?.parse::<u64>().ok())
864+
.map_or(0, |kb| kb.saturating_mul(1024))
865+
}
866+
819867
/// Installed memory in bytes (0 when unknown).
820868
#[cfg(target_os = "macos")]
821869
pub fn read_meminfo() -> u64 {
@@ -892,6 +940,7 @@ pub fn hardware_json(runtime: &Runtime) -> Value {
892940
json!({
893941
"cpu_cores": std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1),
894942
"ram_bytes": ram,
943+
"available_bytes": available_memory(),
895944
"gpu": gpu.map(|g| g.name.clone()),
896945
"vram_bytes": gpu.map(|g| g.total_bytes),
897946
"backend": backend,
@@ -914,6 +963,9 @@ pub struct Budget {
914963
pub runtime_ready: bool,
915964
pub gpu_bytes: Option<u64>,
916965
pub ram_bytes: u64,
966+
/// Memory free for a new process now (0 when unknown): installed memory
967+
/// says whether a model can ever fit, this whether it fits today.
968+
pub available_bytes: u64,
917969
pub context_cap: u64,
918970
pub architectures: Option<Arc<HashSet<String>>>,
919971
}
@@ -924,6 +976,7 @@ impl Budget {
924976
runtime_ready: runtime.ready(),
925977
gpu_bytes: runtime.gpu().map(|g| g.total_bytes),
926978
ram_bytes: read_meminfo(),
979+
available_bytes: available_memory(),
927980
context_cap: config.context_cap(),
928981
architectures: runtime.probe.as_ref().and_then(|p| p.architectures.clone()),
929982
}
@@ -1138,6 +1191,14 @@ pub fn inspect(candidate: &Candidate, budget: &Budget) -> Result<GgufEntry> {
11381191
reason = "The llama.cpp runtime is not ready; see Settings › Local models.".into();
11391192
"setup_required"
11401193
} else {
1194+
// In RAM, or in GPU memory that is the same RAM (Apple silicon).
1195+
let shared = fits == "cpu" || cfg!(target_os = "macos");
1196+
if shared && budget.available_bytes > 0 && memory.total_bytes > budget.available_bytes {
1197+
notes.push(format!(
1198+
"Only {:.1} GB of memory is free right now; close other apps first or it will run very slowly.",
1199+
gb(budget.available_bytes)
1200+
));
1201+
}
11411202
reason = format!(
11421203
"{} · {}-token context · ≈{:.1} GB {}",
11431204
architecture.as_deref().unwrap_or("gguf"),
@@ -1889,6 +1950,7 @@ mod tests {
18891950
runtime_ready: true,
18901951
gpu_bytes: Some(16 * GIB),
18911952
ram_bytes: 64 * GIB,
1953+
available_bytes: 0,
18921954
context_cap: DEFAULT_CONTEXT_CAP,
18931955
architectures: Some(Arc::new(archs.iter().map(|s| s.to_string()).collect())),
18941956
}
@@ -1956,6 +2018,45 @@ mod tests {
19562018
);
19572019
}
19582020

2021+
#[test]
2022+
fn free_memory_is_read_and_never_exceeds_installed() {
2023+
let available = available_memory();
2024+
let installed = read_meminfo();
2025+
eprintln!("available={available} installed={installed}");
2026+
assert!(available > 0 && available <= installed);
2027+
}
2028+
2029+
#[test]
2030+
fn a_model_bigger_than_free_memory_says_so() {
2031+
let dir = tempfile::tempdir().unwrap();
2032+
let path = dir.path().join("small.gguf");
2033+
model(&path, "qwen3", Some("{% if tools %}<tool_call>{% endif %}"));
2034+
let config = LocalEngineConfig {
2035+
files: vec![path.display().to_string()],
2036+
..Default::default()
2037+
};
2038+
// Plenty installed, but the GPU is too small and almost nothing is free.
2039+
let mut short = budget(&["qwen3"]);
2040+
short.gpu_bytes = None;
2041+
short.available_bytes = 1024 * 1024;
2042+
let entries = scan_with(&config, &short);
2043+
assert_eq!(entries[0].availability, "ready");
2044+
assert!(
2045+
entries[0].reason.contains("of memory is free right now"),
2046+
"{}",
2047+
entries[0].reason
2048+
);
2049+
// Unknown (0) or enough free memory: no note.
2050+
short.available_bytes = 0;
2051+
assert!(!scan_with(&config, &short)[0]
2052+
.reason
2053+
.contains("free right now"));
2054+
short.available_bytes = 64 * GIB;
2055+
assert!(!scan_with(&config, &short)[0]
2056+
.reason
2057+
.contains("free right now"));
2058+
}
2059+
19592060
#[test]
19602061
fn metadata_decides_models_projectors_tools_and_compatibility() {
19612062
let dir = tempfile::tempdir().unwrap();

‎native/core/src/redaction.rs‎

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -90,29 +90,86 @@ fn patterns() -> &'static [Regex] {
9090
}
9191

9292
/// High-entropy token heuristic: long base64/hex-like runs outside common words.
93+
///
94+
/// An absolute path under a well-known filesystem root (`/Users/…`,
95+
/// `/Volumes/…`, `/private/var/folders/…`) is judged one segment at a time:
96+
/// as a whole it is long and mixes cases, digits and `/` like base64, which
97+
/// hid the folder of a command on its approval card. A secret that is itself
98+
/// a path segment is still found; a run that does not start at such a root
99+
/// (base64 may contain `/`) is judged whole, as before.
93100
fn high_entropy_tokens(text: &str) -> Vec<(usize, usize)> {
94101
static ENTROPY: OnceLock<Regex> = OnceLock::new();
95102
let re = ENTROPY.get_or_init(|| Regex::new(r"[A-Za-z0-9+/_=-]{32,}").expect("entropy regex"));
96-
re.find_iter(text)
97-
.filter(|m| {
98-
let s = m.as_str();
99-
if s.chars().all(|c| c.is_ascii_digit()) {
100-
return false;
101-
}
102-
let classes = [
103-
s.bytes().any(|b| b.is_ascii_lowercase()),
104-
s.bytes().any(|b| b.is_ascii_uppercase()),
105-
s.bytes().any(|b| b.is_ascii_digit()),
106-
s.bytes()
107-
.any(|b| matches!(b, b'+' | b'/' | b'_' | b'=' | b'-')),
108-
]
109-
.into_iter()
110-
.filter(|v| *v)
111-
.count();
112-
classes >= 3 && shannon(s) >= 3.5
113-
})
114-
.map(|m| (m.start(), m.end()))
115-
.collect()
103+
let mut spans = Vec::new();
104+
for found in re.find_iter(text) {
105+
let run = found.as_str();
106+
if let Some(segments) = path_segments(run) {
107+
spans.extend(segments.filter(|(_, segment)| looks_random(segment)).map(
108+
|(offset, segment)| {
109+
let start = found.start() + offset;
110+
(start, start + segment.len())
111+
},
112+
));
113+
} else if looks_random(run) {
114+
spans.push((found.start(), found.end()));
115+
}
116+
}
117+
spans
118+
}
119+
120+
/// The segments (with their offsets) of an absolute path under a well-known
121+
/// root, or `None` for anything else.
122+
fn path_segments(run: &str) -> Option<impl Iterator<Item = (usize, &str)>> {
123+
const ROOTS: &[&str] = &[
124+
"Users",
125+
"Volumes",
126+
"Applications",
127+
"Library",
128+
"System",
129+
"private",
130+
"home",
131+
"root",
132+
"tmp",
133+
"var",
134+
"opt",
135+
"usr",
136+
"srv",
137+
"mnt",
138+
"media",
139+
"run",
140+
"etc",
141+
"nix",
142+
"proc",
143+
];
144+
// `file:///Users/…` leaves `///Users/…` in the run.
145+
let rest = run.trim_start_matches('/');
146+
let lead = run.len() - rest.len();
147+
let first = rest.split('/').next()?;
148+
if lead == 0 || !ROOTS.contains(&first) || rest.contains("//") || run.contains(['+', '=']) {
149+
return None;
150+
}
151+
Some(rest.split('/').scan(lead, |offset, segment| {
152+
let at = *offset;
153+
*offset += segment.len() + 1;
154+
Some((at, segment))
155+
}))
156+
}
157+
158+
fn looks_random(s: &str) -> bool {
159+
if s.len() < 32 || s.chars().all(|c| c.is_ascii_digit()) {
160+
return false;
161+
}
162+
let classes = [
163+
s.bytes().any(|b| b.is_ascii_lowercase()),
164+
s.bytes().any(|b| b.is_ascii_uppercase()),
165+
s.bytes().any(|b| b.is_ascii_digit()),
166+
s.bytes()
167+
.any(|b| matches!(b, b'+' | b'/' | b'_' | b'=' | b'-')),
168+
]
169+
.into_iter()
170+
.filter(|v| *v)
171+
.count();
172+
classes >= 3 && shannon(s) >= 3.5
116173
}
117174

118175
fn shannon(s: &str) -> f64 {
@@ -584,4 +641,35 @@ mod tests {
584641
assert!(!result.redacted);
585642
assert_eq!(result.text, code);
586643
}
644+
645+
#[test]
646+
fn paths_are_judged_by_segment_so_folders_stay_visible() {
647+
for path in [
648+
"/Volumes/NVME1TB/shadowcode-macos/task-demo",
649+
"/private/var/folders/qq/twswrb793xqgdr954yy4t1dc0000gn/T/.tmpAb3dEf/project",
650+
"/Users/Robert/Projects/ShadowCode2026/native/core/src/redaction.rs",
651+
"/home/dev/work/Project-2026_v2/src/main.rs",
652+
"file:///private/var/folders/qq/twswrb793xqgdr954yy4t1dc0000gn/T/.tmpX1/team.git",
653+
] {
654+
let result = redact_text(&format!("cwd: {path}"));
655+
assert!(!result.redacted, "{path} -> {}", result.text);
656+
}
657+
// A secret that is a whole path segment is still hidden, the rest kept.
658+
let secret = "Zx9Kq2Lm7Pw4Rt6Yv8Nb3Hc5Jd1Fg0Se7Ua2Qi";
659+
let result = redact_text(&format!("/Users/dev/{secret}/notes.txt"));
660+
assert!(
661+
result.redacted && !result.text.contains(secret),
662+
"{}",
663+
result.text
664+
);
665+
assert!(result.text.starts_with("/Users/dev/"), "{}", result.text);
666+
// Base64 that merely contains or starts with '/' is judged whole.
667+
for token in [
668+
"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODw0QFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj",
669+
"dGhpcyBpcyBub3QgYSByZWFsIHNlY3JldC9idXQvaXQ+bG9va3M9bGlrZQ",
670+
"/Users/abc+Zx9Kq2Lm7Pw4Rt6Yv8Nb3Hc5Jd1Fg0Se7Ua2Qi=",
671+
] {
672+
assert!(redact_text(token).redacted, "{token}");
673+
}
674+
}
587675
}

‎native/core/src/rulebook/tests.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -979,11 +979,9 @@ async fn a_git_import_clones_updates_and_never_checks_out_links() {
979979
assert_eq!(fs::read_to_string(&leak).unwrap(), "/etc/passwd");
980980
let book = f.book();
981981
assert!(book.guidance(&f.workspace()).contains("Team rule one."));
982-
// The address itself may be redacted: on macOS the temporary folder
983-
// (/var/folders/<random>/T) reads as a high-entropy token.
984982
assert!(book
985983
.guidance(&f.workspace())
986-
.contains("imported into their ShadowCode profile from file:"));
984+
.contains("imported into their ShadowCode profile from file://"));
987985
let catalog = book.catalog(&f.workspace());
988986
assert!(catalog
989987
.definitions

0 commit comments

Comments
 (0)