Repository navigation
[Wave 3] Medium — Security-contract fixes: Paths, symlinks and TOCTOU #646
Description
Wave 3 · 4 finding(s) in 4 fix package(s). Medium-severity findings that touch the security contract (exit codes, secrets, paths, provenance): do these before the rest of wave 3.
Findings
-
CR-A01-057 (code, Medium) — Config path fields are never
~-expanded: output_dir '/x' splits a run between $HOME (HF checkpoints) and a literal ./directory (final model, audit log, compliance); --resume misses the checkpoin…
severity Medium · reachabilitydefault· intentcontract-violation· confidence confirmed · independently confirmed · reproduced end to end
Failure scenario. An operator answers the wizard's 'Output directory' prompt with/runs/exp1 and trains. Checkpoints land in $HOME/runs/exp1 while the model, audit log and compliance bundle land in .//runs/exp1. A later --resume auto starts from scratch and checkpoint cleanup targets the wrong tree. A wizard-entered model path '/models/x' passes --dry-run and fails at load with exit 2.' with an actionable message. Have the wizard store the expanded value for output_dir and model paths, as BYOD already does. Document path resolution in configuration.md.
Where.forgelm/config.py:451-453,forgelm/cli/_training.py:152,forgelm/cli/_resume.py:13-61,forgelm/utils.py:85-92
Recommended fix. Normalise every path-typed config field with os.path.expanduser in one before-validator, as the wizard save-path and ingestion already do. Alternatively, reject a leading ' -
CR-A07-039 (code, Medium) — Fixed-name temp and artefact writes in output dirs follow pre-planted symlinks (genesis manifest .tmp, pipeline_manifest.json.tmp, bundle .tmp, deployer_instructions.md and others)
severity Medium · reachabilitydefault· intentintended-but-harmful· confidence confirmed · independently confirmed · reproduced end to end
Failure scenario. Create the symlink shared_out/audit_log.jsonl.manifest.json.tmp -> ../victim_outside.txt, then run AuditLogger(shared_out).log_event('training.started'). victim_outside.txt now holds the genesis manifest JSON, and audit_log.jsonl.manifest.json is a symlink.
Where.forgelm/compliance.py:466-472(confirmed),forgelm/compliance.py:3062-3077(open at 3064, os.replace at 3077),forgelm/compliance.py:2311-2318(tmp at 2311, ZipFile open at 2313, os.replace at 2318),forgelm/compliance.py:1761-1763
Recommended fix. Create temp files with tempfile.mkstemp(dir=...) or os.open(O_CREAT|O_EXCL|O_NOFOLLOW), and refuse a symlinked final target. -
CR-A10-033 (code, Medium) — The approval decision lock opens .approval.lock following symlinks with no file-type or inode check
severity Medium · reachabilitydefault· intentintended-but-harmful· confidence confirmed · independently confirmed · reproduced end to end
Failure scenario. Output-dir writer runsln -s audit_log.jsonl <out>/.approval.lock. The approver runsforgelm approve r1 --output-dir <out>. final_model.staging/ is renamed to final_model/, then the process blocks indefinitely in log_event with no output. When the approver kills it, the audit log holds only human_approval.required. The model is live with no granted record, and a retry fails with 'final directory already exists'. reject also hangs, with no rejected event (staging is preserved).
Where.forgelm/cli/subcommands/_approve.py:85,forgelm/cli/subcommands/_approve.py:84,forgelm/cli/subcommands/_approve.py:589,forgelm/cli/subcommands/_approve.py:614
Recommended fix. Open the lock with os.open(path, O_RDWR|O_CREAT|O_NOFOLLOW (|O_CLOEXEC), 0o600). fstat it and refuse anything that is not a regular file with st_nlink==1, owned by the euid, and on a different inode than audit_log.jsonl. Alternatively, place the lock on the audit-log handle itself, sharing one file description with log_event. Consider a bounded LOCK_NB retry loop with a message. Rated low: an out… -
CR-A12-022 (code, Medium) — Input discovery follows file symlinks out of the input tree, pulling arbitrary readable files into the training JSONL
severity Medium · reachabilitydefault· intentintended-but-harmful· confidence confirmed · independently confirmed · reproduced end to end
Failure scenario. An externally supplied corpus tarball contains 'appendix.txt -> (local review artefact)'. After extraction, 'forgelm ingest ./corpus' copies the credentials into the training data. --secrets-mask catches only some formats.
Where.forgelm/ingestion.py:1999-2006(_iter_input_files body, glob at 2003, is_file test at 2005),forgelm/ingestion.py:1997,forgelm/ingestion.py:2005,forgelm/wizard/_byod.py:109-114
Recommended fix. Skip symlinks (entry.is_symlink()), or require entry.resolve() to stay under input_path.resolve(), and log what was skipped. Document the behaviour.
Done when
- Every finding above is fixed or closed with a reason (tick it here).
- Code fixes carry a test; doc fixes update the Turkish mirror in the same PR.
Found by the October 2026 ForgeLM code and documentation review against commit cab2947. Code links point at that commit; line numbers may have moved since. Finding IDs (CR-…, DOC-…, TECH-…) are stable references for this backlog.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity