From 3ce494b56c28b243aeb66aabb620579eab3b3450 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 5 Oct 2026 06:50:04 +0000 Subject: [PATCH] fix: release RUN COMMAND expression temporaries --- docs/CURRENT_STATUS.md | 2 +- src/claro.c | 2 +- ...validate_run_command_expression_cleanup.py | 43 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 tools/validate_run_command_expression_cleanup.py diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 062711f..02afcf8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -38,7 +38,7 @@ The `FIND ... IN ... AS ...` command now releases its evaluated search value and `PUT ... KEY ... VALUE ...` now releases the evaluated map, key, and value copies after runtime storage has copied them. `python3 tools/validate_put_expression_cleanup.py` passes with 2,000 repeated string writes under ASan/UBSan/LSan. Verified 2026-09-26: `make -s all`, `./claro test` (0 failures), `./claro doctor`, `./claro validate`, the focused sanitizer validator, and `git diff --check` pass. Separate malformed-argument sanitizer smoke tests for the existing builtin-arity and inverted-range diagnostics do not report out-of-bounds access, but LeakSanitizer reports the already documented `rt_error` diagnostic-string leaks (552 bytes/14 allocations for arity cases; 79 bytes/2 allocations for the inverted-range case). This slice is runtime-verified; those error-path leaks remain a separate blocker to claiming sanitizer-clean diagnostics. -The `RUN COMMAND` path now decodes POSIX `pclose()` wait status before storing `LASTEXIT`, so a child that exits with code 3 exposes `3` rather than the encoded status 768. Focused coverage is `tests/42_last_exit_code.claro`. HTTP responses now have a 1,048,576-byte cap and marker-like response bodies are preserved while extracting the final HTTP status marker. Focused coverage is `tools/validate_http_hardening.py`. Remaining memory-growth areas include other expression temporaries. Claro remains a trusted-script interpreter, not a sandbox. +The `RUN COMMAND` path now decodes POSIX `pclose()` wait status before storing `LASTEXIT`, so a child that exits with code 3 exposes `3` rather than the encoded status 768. Focused coverage is `tests/42_last_exit_code.claro`. HTTP responses now have a 1,048,576-byte cap and marker-like response bodies are preserved while extracting the final HTTP status marker. Focused coverage is `tools/validate_http_hardening.py`. Remaining memory-growth areas include other expression temporaries. Claro remains a trusted-script interpreter, not a sandbox. `RUN COMMAND` now releases its evaluated command expression and temporary captured-output value after copying output into runtime storage. The new `tools/validate_run_command_expression_cleanup.py` first detected 12,000 bytes leaked across 2,000 command executions, then passed under ASan/UBSan/LSan after the ownership fix. `RUN COMMAND` is documented and validated as a trusted-code capability: it executes shell commands with the user's permissions and is not a sandbox. Claro does not claim untrusted-script safety or use a fragile blacklist sanitizer. Focused documentation coverage is `tools/validate_trusted_command_docs.py`. diff --git a/src/claro.c b/src/claro.c index 0ac689a..8cd5c80 100644 --- a/src/claro.c +++ b/src/claro.c @@ -456,7 +456,7 @@ static void exec_line(Runtime *rt,Program *p,int *pcp,const char *raw){ char buf else { char *ex=substr(pcur,as); Value v=eval_expr(rt,ex); char *txt=v_to_string(v); if(!strcmp(restup,"UPPER")){ char *r=text_upper_copy(txt); rt_set(rt,var,v_str(r)); free(r); } else if(!strcmp(restup,"LOWER")){ char *r=text_lower_copy(txt); rt_set(rt,var,v_str(r)); free(r); } else if(!strcmp(restup,"TRIM")){ char *r=text_trim_copy(txt); rt_set(rt,var,v_str(r)); free(r); } else if(!strcmp(restup,"LENGTH")){ rt_set(rt,var,v_num((double)strlen(txt))); } else if(!strcmp(restup,"CONTAINS")){ char *left=NULL,*right=NULL; const char *needle=find_word_ci(pcur,"NEEDLE"); if(!needle) needle=find_word_ci(pcur,"TEXT"); if(needle){ left=substr(pcur,needle); right=substr(needle+6,as); } else { char **args=NULL; int n=split_args(pcur,&args); if(n>=2){ left=xstrdup(args[0]); right=substr(args[1],as); } split_args_free(args,n); } if(left&&right){ Value lv=eval_expr(rt,left), rv=eval_expr(rt,right); char *ls=v_to_string(lv), *rs=v_to_string(rv); rt_set(rt,var,v_bool(strstr(ls,rs)!=NULL)); free(ls); free(rs); } free(left); free(right); } free(txt); free(ex); } free(var); } return; } if(strcmp(up,"RANDOM")==0){ const char *as=find_word_ci(t,"AS"); if(as&&starts_ci(t+6," NUMBER")){ char *range=substr(t+13,as); char *var=xstrdup(trim_inplace((char*)as+2)); char **args=NULL; int n=split_args(range,&args); double a=1,b=100; if(n>=2){ a=v_number(eval_expr(rt,args[0])); b=v_number(eval_expr(rt,args[1])); } else { char *tmp=range; char *one=unquote_token((const char**)&tmp); char *two=unquote_token((const char**)&tmp); if(*one) a=v_number(eval_expr(rt,one)); if(*two) b=v_number(eval_expr(rt,two)); free(one); free(two); } split_args_free(args,n); if(bcount;i++) if(value_compare(nv,lv.list->items[i])==0){ found=i+1; break; } } rt_set(rt,var,v_num(found)); free(needle); free(listname); free(var); value_free(nv); value_free(lv); } return; } diff --git a/tools/validate_run_command_expression_cleanup.py b/tools/validate_run_command_expression_cleanup.py new file mode 100644 index 0000000..4c29ad7 --- /dev/null +++ b/tools/validate_run_command_expression_cleanup.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Regression check for RUN COMMAND expression temporary ownership.""" +from pathlib import Path +import os +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parent.parent + + +def main() -> int: + if os.name == "nt": + print("SKIP: LeakSanitizer check is POSIX-only") + return 0 + with tempfile.TemporaryDirectory(prefix="claro-run-command-cleanup-") as tmp: + tmp_path = Path(tmp) + binary = tmp_path / "claro-lsan" + script = tmp_path / "run_command_expressions.claro" + script.write_text('RUN COMMAND "true" AS output\n' * 2000, encoding="utf-8") + build = subprocess.run( + ["gcc", "-std=c99", "-O0", "-g", "-fsanitize=address,undefined", + "src/claro.c", "-o", str(binary), "-lm"], + cwd=ROOT, text=True, capture_output=True, + ) + if build.returncode: + print(build.stdout, end="") + print(build.stderr, end="") + return build.returncode + run = subprocess.run( + [str(binary), str(script)], cwd=ROOT, text=True, capture_output=True, + env={**os.environ, "ASAN_OPTIONS": "detect_leaks=1"}, + ) + if run.returncode or "LeakSanitizer" in run.stderr or "SUMMARY:" in run.stderr: + print("FAIL: RUN COMMAND expression values still leak") + print(run.stdout, end="") + print(run.stderr, end="") + return 1 + print("PASS: RUN COMMAND expression values are released") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file