Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
8 changes: 7 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ cargo build
cargo test --workspace
```

The workspace has four crates: `lowfat-core`, `lowfat-plugin`, `lowfat-runner`, and the `lowfat` CLI.
To verify Linux from a Mac, run the suite in Docker (CI runs both):

```sh
docker run --rm -v "$PWD":/work -w /work -e CARGO_TARGET_DIR=/tmp/target rust:1 cargo test --workspace
```

The workspace has five crates: `lowfat-core`, `lowfat-compress`, `lowfat-plugin`, `lowfat-runner`, and the `lowfat` CLI.

## Pull requests

Expand Down
4 changes: 2 additions & 2 deletions crates/lowfat-compress/src/code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ fn strip_block_comments(content: &str, spec: &LangSpec) -> String {
continue;
}
if let Some(q) = docstring {
if trimmed.starts_with(q) {
if let Some(rest) = trimmed.strip_prefix(q) {
result.push_str(line);
result.push('\n');
// Opener with no closer on the same line → swallow until it closes.
if !trimmed[q.len()..].contains(q) {
if !rest.contains(q) {
in_doc = true;
}
continue;
Expand Down
2 changes: 1 addition & 1 deletion crates/lowfat-compress/src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ fn full(content: &str) -> String {
result.push_str(line);
result.push('\n');
} else if table_rows == 11 {
result.push_str(&format!("| ... [more rows] |\n"));
result.push_str("| ... [more rows] |\n");
}
continue;
} else {
Expand Down
40 changes: 40 additions & 0 deletions crates/lowfat-core/src/env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! Env allowlist for plugin subprocesses. Secrets (AWS keys, tokens,
//! API keys) must never reach plugin code — start from env_clear() and
//! pass only these through.

use std::collections::HashSet;

const SAFE_ENV_VARS: &[&str] = &[
"LOWFAT_LEVEL",
"LOWFAT_COMMAND",
"LOWFAT_SUBCOMMAND",
"LOWFAT_EXIT_CODE",
"PATH",
"HOME",
"USER",
"SHELL",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TMPDIR",
"GIT_DIR",
"GIT_WORK_TREE",
"DOCKER_HOST",
"KUBECONFIG",
"GOPATH",
"GOROOT",
"CARGO_HOME",
"RUSTUP_HOME",
"NODE_PATH",
"NPM_CONFIG_PREFIX",
"VIRTUAL_ENV",
"PYTHONPATH",
];

pub fn sanitized_env() -> Vec<(String, String)> {
let safe: HashSet<&str> = SAFE_ENV_VARS.iter().copied().collect();
std::env::vars()
.filter(|(k, _)| safe.contains(k.as_str()))
.collect()
}
45 changes: 26 additions & 19 deletions crates/lowfat-core/src/lf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,10 +562,7 @@ impl<'a> Parser<'a> {
/// Stops at first significant line whose indent <= parent_indent.
fn parse_indented_ops(&mut self, parent_indent: usize) -> Result<Vec<Op>> {
let mut ops = Vec::new();
loop {
let Some(line) = self.peek_significant() else {
break;
};
while let Some(line) = self.peek_significant() {
if line.indent <= parent_indent {
break;
}
Expand Down Expand Up @@ -598,10 +595,7 @@ impl<'a> Parser<'a> {
fn parse_cascade(&mut self, parent_indent: usize) -> Result<Vec<Branch>> {
let mut branches: Vec<Branch> = Vec::new();
let mut arm_indent: Option<usize> = None;
loop {
let Some(line) = self.peek_significant() else {
break;
};
while let Some(line) = self.peek_significant() {
if line.indent <= parent_indent {
break;
}
Expand Down Expand Up @@ -721,10 +715,7 @@ impl<'a> Parser<'a> {

let mut branches: Vec<Branch> = Vec::new();
let mut arm_indent: Option<usize> = None;
loop {
let Some(line) = self.peek_significant() else {
break;
};
while let Some(line) = self.peek_significant() {
if line.indent <= parent_indent {
break;
}
Expand Down Expand Up @@ -890,7 +881,7 @@ impl<'a> Parser<'a> {
self.pos += 1;
}
// Trim trailing blank lines (they belong to the gap, not the body).
while collected.last().map_or(false, |l| l.text.is_empty()) {
while collected.last().is_some_and(|l| l.text.is_empty()) {
collected.pop();
}
if collected.is_empty() {
Expand All @@ -917,10 +908,7 @@ impl<'a> Parser<'a> {
fn parse_split_branches(&mut self, parent_indent: usize) -> Result<(Vec<Op>, Vec<Op>)> {
let mut pre = Vec::new();
let mut post = Vec::new();
loop {
let Some(line) = self.peek_significant() else {
break;
};
while let Some(line) = self.peek_significant() {
if line.indent != parent_indent {
break;
}
Expand Down Expand Up @@ -1762,6 +1750,8 @@ fn run_filter_child(
ctx: &ExecCtx,
) -> Result<String> {
let mut child = cmd
.env_clear()
.envs(crate::env::sanitized_env())
.env("level", ctx.level.to_string())
.env("sub", ctx.sub)
.env("exit", ctx.exit_code.to_string())
Expand Down Expand Up @@ -2033,7 +2023,7 @@ diff, ultra: compact 30 else-shell: awk 'NF' | head -50
assert!(matches!(&ops[0], Op::MacroCall { name, .. } if name == "compact"));
match &ops[1] {
Op::OrShell(s) => assert_eq!(s, "awk 'NF' | head -50"),
_ => panic!("expected OrShell, got {:?}", &ops[1]),
_ => panic!("expected OrShell, got {:?}", ops[1]),
}
}

Expand Down Expand Up @@ -2361,6 +2351,19 @@ build:
assert_eq!(out, "build:ultra\n");
}

#[test]
fn exec_shell_does_not_see_parent_secrets() {
std::env::set_var("LF_TEST_FAKE_SECRET", "leaked");
let rs = parse_ok(
r#"
build:
shell: printf '[%s]' "$LF_TEST_FAKE_SECRET"
"#,
);
let out = execute(&rs, &ctx("build", Level::Full), "").unwrap();
assert_eq!(out, "[]\n");
}

#[test]
fn exec_else_shell_uses_raw_input() {
let rs = parse_ok(
Expand Down Expand Up @@ -3012,7 +3015,11 @@ plan:
#[test]
fn absolute_include_path_rejected() {
let d = tempfile::tempdir().unwrap();
let root = write(d.path(), "main.lf", "include /etc/passwd.lf\n*:\n head 1\n");
let root = write(
d.path(),
"main.lf",
"include /etc/passwd.lf\n*:\n head 1\n",
);
let err = format!("{:#}", load(&root).unwrap_err());
assert!(err.contains("must be relative"), "got: {err}");
}
Expand Down
1 change: 1 addition & 0 deletions crates/lowfat-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod config;
pub mod db;
pub mod env;
pub mod level;
pub mod lf;
pub mod pipeline;
Expand Down
36 changes: 23 additions & 13 deletions crates/lowfat-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ impl Pipeline {
.split('|')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|raw| parse_pipeline_stage(raw))
.map(parse_pipeline_stage)
.collect();
Pipeline { stages }
}
Expand Down Expand Up @@ -219,17 +219,15 @@ pub fn proc_strip_ansi(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
if ch == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if c.is_ascii_alphabetic() {
break;
}
if c.is_ascii_alphabetic() {
break;
}
continue;
}
continue;
}
result.push(ch);
}
Expand Down Expand Up @@ -257,8 +255,12 @@ pub fn proc_token_budget(text: &str, max_tokens: usize) -> String {
return text.to_string();
}
let ratio = max_tokens as f64 / current as f64;
let target_chars = (text.len() as f64 * ratio) as usize;
let mut result = text[..target_chars.min(text.len())].to_string();
// Byte offset from a ratio — back up to a char boundary before slicing.
let mut cut = ((text.len() as f64 * ratio) as usize).min(text.len());
while !text.is_char_boundary(cut) {
cut -= 1;
}
let mut result = text[..cut].to_string();
if let Some(pos) = result.rfind('\n') {
result.truncate(pos);
}
Expand Down Expand Up @@ -385,7 +387,7 @@ pub fn proc_cut(text: &str, spec: &str) -> String {
for &(start, end) in &ranges {
let end = end.min(n);
for i in start..=end {
if let Some(&field) = parts.get(i.checked_sub(1).unwrap_or(0)) {
if let Some(&field) = parts.get(i.saturating_sub(1)) {
if i >= 1 {
selected.push(field);
}
Expand Down Expand Up @@ -419,7 +421,7 @@ pub fn apply_builtin(
Some(proc_truncate(text, limit))
}
"token-budget" => {
let budget = param.unwrap_or_else(|| match level {
let budget = param.unwrap_or(match level {
Level::Lite => 2000,
Level::Full => 1000,
Level::Ultra => 500,
Expand Down Expand Up @@ -585,6 +587,14 @@ mod tests {
assert!(result.contains("truncated to"));
}

#[test]
fn token_budget_multibyte_no_panic() {
// cut lands mid-char without the boundary backoff (issue: panic on ✓/→/emoji)
let input = "✓".repeat(200);
let result = proc_token_budget(&input, 20);
assert!(result.contains("truncated to"));
}

#[test]
fn dedup_blank() {
let input = "line1\n\n\n\nline2\n\nline3";
Expand Down
1 change: 1 addition & 0 deletions crates/lowfat-core/src/structured.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ fn caps(level: Level) -> (usize, usize) {
}
}

#[cfg(test)]
fn is_valid_json(text: &str) -> bool {
serde_json::from_str::<serde::de::IgnoredAny>(text.trim()).is_ok()
}
Expand Down
2 changes: 1 addition & 1 deletion crates/lowfat-core/src/tee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub fn save_on_failure(tee_dir: &Path, label: &str, raw: &str, exit_code: i32) {
return;
}

let safe_label = label.replace(' ', "_").replace('/', "_");
let safe_label = label.replace([' ', '/'], "_");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
Expand Down
2 changes: 1 addition & 1 deletion crates/lowfat-core/src/tokens.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// Estimate token count from text. ~4 chars = 1 token.
/// Matches bash: `(len + 3) / 4`
pub fn estimate_tokens(s: &str) -> usize {
(s.len() + 3) / 4
s.len().div_ceil(4)
}

#[cfg(test)]
Expand Down
38 changes: 3 additions & 35 deletions crates/lowfat-plugin/src/security.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
//! Plugin security: path traversal checks, hook validation, env sanitization, trust.

use crate::manifest::PluginManifest;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -158,45 +157,14 @@ pub fn untrust_plugin(plugin_name: &str, lowfat_home: &Path) -> anyhow::Result<(

// --- Environment sanitization ---

const SAFE_ENV_VARS: &[&str] = &[
"LOWFAT_LEVEL",
"LOWFAT_COMMAND",
"LOWFAT_SUBCOMMAND",
"LOWFAT_EXIT_CODE",
"PATH",
"HOME",
"USER",
"SHELL",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TMPDIR",
"GIT_DIR",
"GIT_WORK_TREE",
"DOCKER_HOST",
"KUBECONFIG",
"GOPATH",
"GOROOT",
"CARGO_HOME",
"RUSTUP_HOME",
"NODE_PATH",
"NPM_CONFIG_PREFIX",
"VIRTUAL_ENV",
"PYTHONPATH",
];

pub fn sanitized_env() -> Vec<(String, String)> {
let safe: HashSet<&str> = SAFE_ENV_VARS.iter().copied().collect();
std::env::vars()
.filter(|(k, _)| safe.contains(k.as_str()))
.collect()
}
// Allowlist lives in lowfat-core so the .lf exec path can use it too.
pub use lowfat_core::env::sanitized_env;

#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::PluginManifest;
use std::collections::HashSet;

fn minimal_manifest(entry: &str) -> PluginManifest {
let toml = format!(
Expand Down
4 changes: 2 additions & 2 deletions crates/lowfat/src/commands/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ pub fn run(limit: usize) -> Result<()> {

println!("Recent plugin activity:");
println!(
" {:20} {:20} {:8} {:10} {:12} {}",
"timestamp", "plugin", "runtime", "command", "action", "details"
" {:20} {:20} {:8} {:10} {:12} details",
"timestamp", "plugin", "runtime", "command", "action"
);
println!(" {}", "-".repeat(90));

Expand Down
Loading
Loading