Skip to content

[Wave 3] Medium — Security-contract fixes: Verifier, gate and integrity routing #652

Open

Description

@cemililik

Wave 3 · 17 finding(s) in 17 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-033 (code, Medium) — moe.experts_to_train is unvalidated: malformed values train all experts; all-out-of-range values freeze every expert adapter
    severity Medium · reachability default · intent intended-but-harmful · confidence confirmed · independently confirmed · not reproduced (code trace)
    Failure scenario. experts_to_train: '0;1' trains all experts; 'experts_to_train: 8,9' on an 8-expert model trains no expert adapters.
    Where. forgelm/config.py:125, forgelm/model.py:700-717, forgelm/model.py:804-819, forgelm/model.py:510-511
    Recommended fix. Add a field validator (all | comma-separated non-negative ints) and raise ConfigError for an empty post-filter set.

  • CR-A02-058 (code, Medium) — The DeepSpeed config that drives training is not bound to any artefact: its resolved path and digest appear in no manifest, audit event, model card or config_hash
    severity Medium · reachability documented-opt-in · intent intended-but-harmful · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. An operator runs distributed.deepspeed_config: zero2 twice. Between the runs, someone with write access to the shared project directory (or a sloppy dependency's site-packages/configs/) replaces zero2.json. The new file changes the optimizer type, sets stage3_gather_16bit_weights_on_model_save:false, or adds an offload nvme_path under a world-readable directory. Both runs produce the same config_hash, an identical manifest and an identical model card. An auditor comparing artefacts cannot detec…
    Where. forgelm/trainer.py:591-599, forgelm/trainer.py:617-670, forgelm/compliance.py:1331-1364, forgelm/compliance.py:1491-1548
    Recommended fix. At resolution time read the file once, compute its sha256, and pass the parsed dict (not the path) to TrainingArguments, which also removes the TOCTOU re-read. Record {source: preset|cwd|custom, path, sha256} in the manifest, the training.started audit event and the model card.

  • CR-A03-043 (code, Medium) — Adapter dir as model.name_or_path: transformers loads the base model named in adapter_config.json without a pin or a record, and component_revisions names only the adapter dir
    severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. An operator fine-tunes 'on top of' a downloaded adapter directory whose adapter_config.json names base_model_name_or_path 'attacker/poisoned-base'. ForgeLM trains on that unpinned repo, discards the adapter, and writes an Annex IV manifest and component_revisions naming only the local adapter dir with no revision, so the real base model is absent from the provenance.
    Where. forgelm/model.py:609, forgelm/model.py:585, forgelm/model.py:589, forgelm/config.py:252
    Recommended fix. In get_model_and_tokenizer, detect adapter_config.json in name_or_path (as cli/_no_train_modes.py:35 already does). Either refuse with a config error that points the operator at the base model plus an adapter field, or resolve, pin and record base_model_name_or_path as the base_model role and load the adapter as trainable instead of re-initialising it.

  • CR-A04-022 (code, Medium) — No-train modes: failing-gate exit codes (benchmark-only 3, merge 2) and the whole merge body are untested; errors escape main() raw
    severity Medium · reachability documented-opt-in · intent contract-violation · confidence confirmed · independently confirmed · reproduced end to end · already tracked: KNOWN-OPEN C33-CLI-ERROR-ENVELOPE — docs/roadmap/phase-16-trust-surface-hardening.md:505-506 (S7 open)
    Failure scenario. A refactor breaks the benchmark-only min-score gate. A below-threshold model passes CI with exit 0 because no test fails. A merge failure exits 0 with only a log line.
    Where. forgelm/cli/_no_train_modes.py:115-125, forgelm/cli/_no_train_modes.py:134-177, forgelm/cli/_no_train_modes.py:264-283, tests/test_no_train_modes.py:33-39
    Recommended fix. Add benchmark failed (exit 3, JSON failure_reason) and merge success/failure (exit 2) tests plus dispatcher route tests. Decide whether the no-train modes should convert exceptions into the exit-code and JSON-envelope contract, and test it.

  • CR-A07-026 (code, Medium) — Offline/cache revision provenance takes the commit SHA from a shared-cache directory name and records it as a confirmed revision
    severity Medium · reachability default · intent intended-but-harmful · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. On a shared GPU host with a common HF_HOME, another user plants modified weights under snapshots//. The air-gapped run trains on them, and the manifest certifies the genuine upstream SHA, so an auditor comparing SHAs sees a match.
    Where. forgelm/compliance.py:1169-1195, forgelm/compliance.py:1315-1319, forgelm/model.py:136-141, forgelm/compliance.py:1394-1396
    Recommended fix. Correct the docstrings: the cache source is name-based, not verified. Label cache-sourced SHAs as unverified in the manifest (separately from Hub-confirmed ones), or hash the loaded weight files into the base-model lineage block.

  • CR-A07-033 (code, Medium) — verify-integrity ignores the writer's skipped_symlinks (and exported_artifacts), so an untouched directory the writer attested fails with exit 6
    severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced end to end · already tracked: KNOWN-OPEN CLI-03 — docs/roadmap/phase-16-trust-surface-hardening.md:426-429 (S5 open)
    Failure scenario. A deployer symlinks shared tokenizer files into the model dir, generates the integrity manifest and gets a permanent exit 6 from verify-integrity.
    Where. forgelm/compliance.py:795-815 (confirmed), forgelm/verify/_model_integrity.py:197-206, forgelm/verify/_model_integrity.py:111-146 (confirmed), forgelm/verify/_model_integrity.py:199-206
    Recommended fix. Have the verifier treat manifest-listed skipped_symlinks consistently: report them as a non-integrity 'unverifiable' class (exit 1), or refuse them at generation time.

  • CR-A07-042 (code, Medium) — verify-annex-iv --pipeline crashes with TypeError on a non-string metadata.manifest_hash; the manifest-hash-mismatch path is never fed a non-string
    severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. A tampered pipeline manifest is reported as exit 1 with a traceback rather than 6.
    Where. forgelm/compliance.py:2081-2090, forgelm/cli/subcommands/_verify_annex_iv.py:162-191, forgelm/verify/_pipeline_evidence.py:616
    Recommended fix. Coerce with str() or type-check before slicing, and add a hash-type parametrized test across the single and pipeline verifiers.

  • CR-A07-047 (code, Medium) — Art. 10 audit-report inlining: any parseable JSON suppresses the gap event, deep nesting loses the whole governance report, and a '~' output_dir disconnects the reader from the audit writer
    severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. (a) A placeholder or stray JSON file at <output_dir>/data_audit_report.json, e.g. '{}' left by a CI step or an unrelated JSON copied there, produces a governance bundle and audit log with no gap event; an auditor reading the log concludes the Art. 10 data-quality section shipped. (b) A report saved with a BOM by Windows tooling is logged as absent although it is present. (c) An operator writes output_dir: ~/runs/policy in YAML and runs forgelm audit data.jsonl --output ~/runs/policy as the do…
    Where. forgelm/compliance.py:668-704 (_maybe_inline_audit_report: raw os.path.join(output_dir,...), no expanduser, no type/schema check, except (json.JSONDecodeError, OSError,…, forgelm/compliance.py:729-732 (data_audit_inlined = audit is not None), forgelm/trainer.py:2093-2124 (gap-event condition at 2109-2114; broad except -> compliance.governance_failed at 2116-2124), forgelm/trainer.py:345 (checkpoint_dir = raw training.output_dir), forgelm/trainer.py:354 (AuditLogger(self.checkpoint_dir)), forgelm/trainer.py:497 (raw output_d…
    Recommended fix. Validate the inlined object: require a dict with the audit report's identifying keys (for example generated_at, splits/total_samples, and source_path matching data.dataset_name_or_path). Record distinct events for 'absent' and 'present but invalid/unreadable (reason)'. Catch RecursionError/ValueError in the reader, or cap the size and depth. Apply os.path.expanduser consistently to training.outpu…

  • CR-A07-056 (code, Medium) — API snapshot gate misses stable method signatures, field types and nested config fields; same-version regeneration passes
    severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. A contributor changes AuditLogger.log_event(self, event: str, **details) to (self, name: str, , severity: str, **details), or renames a nested config field such as training.num_train_epochs. The snapshot test stays green and api_version stays 1.2.0, so library consumers who pin on api_version break without warning. Alternatively, a contributor breaks verify_audit_log's signature and regenerates api_signatures_1.2.0.json: the test passes without a MAJOR bump.
    Where. tests/test_library_api.py:773-786, tests/test_library_api.py:798-814, tests/_data/api_signatures_1.0.0.json, tests/typing/public_surface_probe.py:97
    Recommended fix. Extend describe so it records: the public-method signatures of classes in all; dataclass field types and defaults; a recursive Pydantic schema, for example model_json_schema() or fields with annotations, for nested models. Add a test that loads the previous api_signatures
    .json and asserts that every stable entry is unchanged unless the MAJOR component went up, so regenerating the current f…

  • CR-A07-057 (code, Medium) — Pipeline verifier: deleting one audit line downgrades a proven tamper from exit 6 to exit 1
    severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced (plumbing only)
    Failure scenario. Run a 3-stage pipeline with FORGELM_AUDIT_SECRET set. Edit pipeline_manifest.json: stage 3 status becomes skipped_by_filter, gate_decision is dropped, and the manifest is re-stamped with compute_annex_iv_manifest_hash. Delete the last line of /audit_log.jsonl. forgelm verify-annex-iv <run> --pipeline then exits 1 instead of 6, and alarms wired to 6 do not fire.
    Where. forgelm/compliance.py:3491-3519 (live-vs-manifest comparison that is never reached), forgelm/compliance.py:3491-3511, forgelm/verify/_pipeline_evidence.py:531-534 (unconditional call in --pipeline mode), docs/usermanuals/en/compliance/annex-iv.md:165,172
    Recommended fix. Compute the live/passed census comparison first and return 'contradicted' whenever it finds a mismatch. Fall back to unattested/run_terminal_event_absent only when the remaining authenticated events agree with the manifest.

  • CR-A09-019 (code, Medium) — verify-gguf trusts an unkeyed, never-exporter-written sidecar to downgrade a metadata-parse failure from exit 6 to exit 1 ('not a corrupted artifact')
    severity Medium · reachability default · intent intended-but-harmful · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. An attacker truncates model.gguf and writes sha256sum model.gguf > model.gguf.sha256. verify-gguf exits 1 with 'almost certainly a gguf package version ... not a corrupted artifact'. CI alarms wired to exit 6 stay silent.
    Where. forgelm/verify/_gguf.py:153-173 (confirmed), forgelm/verify/_gguf.py:256-268, forgelm/verify/_gguf.py:268, docs/reference/verify_gguf_subcommand.md:46
    Recommended fix. Do not downgrade to exit 1 on a matching sidecar unless the sidecar's digest is anchored to a keyed record, such as an HMAC-tagged export audit event. Otherwise keep the parse failure at 6 and say that the sidecar is unauthenticated. Reword the message and verify-gguf.md:112 / verify_gguf_subcommand.md:32,43 (+TR) to 'matches the sidecar', not 'what the exporter wrote'.

  • CR-A09-023 (code, Medium) — Strict-mode audit-log reader swallows OSError on open, so the approve/reject 'no prior decision' guard fails open on a transient read failure and every strict caller misreports an unreadable log
    severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced with stubs
    Failure scenario. Operator runs forgelm reject --run-id r1; staging is kept. Later forgelm approve --run-id r1 runs while the audit log is briefly unreadable (network-FS flap, or a concurrent chmod by an output-dir writer) during the decision lookup only. The guard sees no decision, staging is promoted, human_approval.granted is appended after human_approval.rejected, exit 0.
    Where. forgelm/cli/subcommands/_audit_log_reader.py:160-164 (swallowing open/except; the cited 158-164 range also holds the isfile guard), forgelm/cli/subcommands/_audit_log_reader.py:221-227, forgelm/cli/subcommands/_audit_log_reader.py:246-248, forgelm/cli/subcommands/_approve.py:404-452 (_read_required_event_for_reject, which uses lines 419 and 436) and 455-509 (_read_required_event_for_approve, which uses lines 476 and 493…
    Recommended fix. In strict mode, re-raise the open OSError (or raise a dedicated AuditLogReadError) instead of returning empty. In approve/reject, catch it and exit with an actionable 'audit log unreadable: ' message. Read required + decision events in one pass, from one file handle, under the decision lock, so the two lookups cannot see different file states. Have --show reuse the chain-read handle for th…

  • CR-A09-025 (code, Medium) — verify-annex-iv returns on missing/blank required fields before the hash compare, downgrading a hash-mismatched edit from exit 6 to exit 1
    severity Medium · reachability default · intent intended-but-harmful · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. An adversary or careless editor clears the known_limitations or risk_management section of a signed-off Annex IV artefact. CI that treats 1 as an operator typo and 6 as a security event files it as 'populate your config' rather than tampering.
    Where. forgelm/verify/_annex_iv.py:162-178, forgelm/verify/_annex_iv.py:164-171 (confirmed), forgelm/verify/_annex_iv.py:222-227, docs/reference/verify_annex_iv_subcommand.md:37
    Recommended fix. When metadata.manifest_hash is present, compute and compare it first. A mismatch should route to 6 whether or not fields are missing, and the missing-field gap should still be reported as additional detail.

  • CR-A09-030 (code, Medium) — verify-annex-iv --pipeline: dropping or blanking the unkeyed manifest's annex_iv block downgrades deleted per-stage evidence from exit 6 to exit 1
    severity Medium · reachability default · intent intended-but-harmful · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. Someone with write access to an archived pipeline run deletes /compliance/annex_iv_metadata.json. They then delete the 'annex_iv' key, or blank its three identity fields, from compliance/pipeline_manifest.json, and either re-stamp metadata.manifest_hash with forgelm.compliance.compute_annex_iv_manifest_hash or delete the metadata block. forgelm verify-annex-iv --pipeline

    --output-format json now exits 1 (UNVERIFIED) instead of 6, with hash_state 'verified' and audit corroboration '…
    Where. forgelm/verify/_pipeline_evidence.py:252-271 (confirmed), forgelm/verify/_pipeline_evidence.py:298-321 (confirmed), forgelm/verify/_pipeline_evidence.py:366, forgelm/verify/_pipeline_evidence.py:505-507
    Recommended fix. (1) Stop routing on the unkeyed manifest alone. When FORGELM_AUDIT_SECRET is set, open the stage-level log next to the pointer (dirname(dirname(pointer))/audit_log.jsonl) and verify it with require_hmac. If its last compliance.artifacts_exported event lists annex_iv_metadata.json, missing evidence is a VIOLATION (6) whatever the manifest says. If it records compliance.artifacts_export_failed, rou…

  • CR-A10-031 (code, Medium) — Purge --kind staging and approve are not coordinated: approve promotes an emptied or partly purged staging dir and logs human_approval.granted (exit 0), while purge logs a false data.erasure_failed
    severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. An output dir holds human_approval.required for R and final_model.staging.R/ with 20 files. An operator runs forgelm purge --run-id R --kind staging for a DSAR. While rmtree is unlinking, the reviewer runs forgelm approve R. approve exits 0 and logs human_approval.granted with final_model_path=/final_model, but final_model/ is empty. purge exits 2 and logs data.erasure_failed FileNotFoundError, files_modified=[], '0 of 1 target(s) removed'. verify-audit reports OK. Alternatively the pu…
    Where. forgelm/cli/subcommands/_purge.py:821-949 (_run_purge_run_id: no lock, no approval-state read, no human_approval.* awareness), forgelm/cli/subcommands/_purge.py:852, forgelm/cli/subcommands/_purge.py:914-931, forgelm/cli/subcommands/_purge.py:1092-1100 (_delete_path: rglob size sum then shutil.rmtree)
    Recommended fix. (1) In _run_purge_run_id for kind=staging, hold _approval_decision_lock(output_dir) across the target resolution and the deletion. Rename the staging dir to a tombstone name before rmtree, so a concurrent promotion can never pick up a half-deleted tree. (2) When the run has no terminal decision, refuse unless an explicit flag is passed, or append a closing event (e.g. human_approval.withdrawn / r…

  • CR-A11-039 (code, Medium) — UTF-8 BOM (Windows editors) in JSONL: first row is skipped by audit and purge, and turns the first safety/judge probe into literal JSON text
    severity Medium · reachability default · intent intended-but-harmful · confidence confirmed · independently confirmed · reproduced end to end
    Failure scenario. The first adversarial probe is evaluated as a garbled JSON-looking string, so the safety gate scores something other than the declared probe; a data subject whose record is the first line cannot be erased and is told no row exists.
    Where. forgelm/data_audit/_streaming.py:205-213, forgelm/cli/subcommands/_purge.py:280-288, forgelm/safety/_inputs.py:36-44, forgelm/judge.py:317-326
    Recommended fix. Open JSONL readers with encoding utf-8-sig (or strip a leading U+FEFF) in one shared helper, and make purge report unparseable lines it skipped.

  • CR-A11-040 (code, Medium) — ML-NER PII detection silently returns {} for any row that makes Presidio/spaCy raise (e.g. rows over 1,000,000 characters); logged only at DEBUG with no counter, so the audit report under-reports PII
    severity Medium · reachability documented-opt-in · intent intended-but-harmful · confidence confirmed · independently confirmed · reproduced end to end · already tracked: KNOWN-OPEN TRUST-06 — docs/roadmap/phase-16-trust-surface-hardening.md:585,601-602 (S10 open)
    Failure scenario. The operator audits a scraped corpus with --pii-ml before training and gets a clean ML PII section. Oversized rows were never scanned, and the PII flows into the SFT dataset.
    Where. forgelm/data_audit/_pii_ml.py:236-262, forgelm/data_audit/_aggregator.py:149-158
    Recommended fix. Count rows where ML detection errored (or chunk long text under max_length). Surface 'pii_ml_rows_failed' in the report and notes, and log at WARNING once per run.

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.

Activity

  1. added
    bugSomething isn't working
    backlog: knownAlready tracked in docs/roadmap
    securitySecurity-relevant
    severity: mediumReal defect with limited reach or a workaround
    source: review-2026-10Found by the October 2026 code and documentation review
    wave: 3Roadmap wave 3: medium severity, module sweeps
    on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: complianceCode area: compliancearea: configCode area: configarea: data-auditCode area: data-auditbacklog: knownAlready tracked in docs/roadmapbugSomething isn't workingsecuritySecurity-relevantseverity: mediumReal defect with limited reach or a workaroundsource: review-2026-10Found by the October 2026 code and documentation reviewwave: 3Roadmap wave 3: medium severity, module sweeps

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions