How it works¶
The whole tool is three Python files, about 550 lines in total:
| File | Job |
|---|---|
modelaudit/detect.py |
Walks the folder, finds checkouts and model folders, works out each license. |
modelaudit/licenses.py |
The rules. Turns (license, ship mode) into a severity and an explanation. |
modelaudit/__main__.py |
Command line, terminal report, JSON output, exit code. |
path ──► scan() ──► [{kind, path, name, license, source, published?}, ...]
detect.py │
▼
classify(license, ship, kind, published)
licenses.py
│
▼
sort by severity ──► report or JSON ──► exit 0 / 1
__main__.py
1. The walk¶
scan() uses os.walk from the root you give it.
- Folder depth is counted from the root (root is
0). At depth--depthor more the walk stops going down. - Folders in
SKIP_DIRSand any folder whose name starts with.are skipped.~/.cacheis skipped for this reason, so scan~/.cache/huggingface/hubdirectly. - Each folder is checked as a git checkout first, then as a model folder.
2. Git checkouts (kind: code)¶
A folder counts as a checkout if it contains .git (a directory, or a file as in
submodules and worktrees).
- The remote URL is the first
url = ...line in.git/config. If it points atgithub.com, the name becomesowner/repo. - The license comes from the first file (sorted by name) starting with
license,licenceorcopying, matched by text (see license matching). - If that gives nothing and the network is allowed, it asks
https://api.github.com/repos/owner/repofor the SPDX id.NOASSERTIONcounts as nothing. - If the network is allowed, a second request to the same endpoint checks
whether the repo is public. This sets
published.
Checkouts are de-duplicated by GitHub slug. Every checkout without a GitHub remote
has the slug None, so only the first of them is reported. This is a known
limit listed in the README.
The walk keeps going into a checkout's subfolders, so vendored models or nested checkouts inside it are still found.
3. Model weights (kind: weights)¶
A folder is treated as a model when any of these is true:
- its path ends in a Hugging Face cache snapshot,
models--<org>--<name>/snapshots/<hex hash>; - it holds a file ending in
.safetensors,.gguf,.bin,.mlmodelc,.mlpackage,.onnx,.pt,.pth,.tfliteor.ckpt, or a Core ML bundle folder ending in.mlpackageor.mlmodelc; - it holds
model_index.json(diffusers models such as FLUX and Stable Diffusion keep their weights in subfolders); - it holds
config.jsonand any file whose name starts withmodel.
Once a folder is a model, the walk does not go inside it. That keeps a multi-part diffusers model as one finding.
The Hugging Face repo id comes from the models--org--name part of the path, or
else from _name_or_path, name_or_path or model_name in config.json (a value
containing / that does not start with /).
License resolution:
- A local license file, matched by text.
- If none was recognized and there is a repo id and the network is allowed,
https://huggingface.co/api/models/<repo_id>is read. The license iscardData.license(first item if it is a list). If that is missing,otherorunknown,cardData.license_nameis used instead. This is how FLUX.1-dev resolves toflux-1-dev-non-commercial-license. - Otherwise
unknown.
License matching¶
license_from_text() reads the first 20,000 characters and checks these
signatures case-insensitively. A signature matches when every string is present.
License texts name each other (LGPL-2.1 builds on "the GNU General Public
License", MPL-2.0 lists the GPL family as secondary licenses), so when several
match, the one whose first string appears earliest in the text wins. That is
normally the license's own title. Ties go to the earlier row:
| Result | Must contain |
|---|---|
AGPL-3.0 |
"GNU AFFERO GENERAL PUBLIC LICENSE" |
GPL-3.0 |
"GNU GENERAL PUBLIC LICENSE" and "Version 3" |
GPL-2.0 |
"GNU GENERAL PUBLIC LICENSE" and "Version 2" |
LGPL-3.0 |
"GNU LESSER GENERAL PUBLIC LICENSE" and "Version 3" |
LGPL-2.1 |
"GNU LESSER GENERAL PUBLIC LICENSE" and "Version 2.1" |
Apache-2.0 |
"Apache License" and "Version 2.0" |
MPL-2.0 |
"Mozilla Public License Version 2.0" |
BSD-3-Clause |
"Redistribution and use in source and binary forms" and "Neither the name of" |
BSD-2-Clause |
"Redistribution and use in source and binary forms" |
ISC |
"Permission to use, copy, modify, and/or distribute this software" |
Unlicense |
"This is free and unencumbered software released into the public domain" |
MIT |
"Permission is hereby granted, free of charge" |
CC-BY-NC-SA-4.0 |
"Attribution-NonCommercial-ShareAlike" |
CC-BY-NC-4.0 |
"Attribution-NonCommercial" |
CC-BY-SA-4.0 |
"Attribution-ShareAlike" |
If nothing matches but the text contains "non-commercial" or "noncommercial", the
result is non-commercial (custom), which is a blocker.
Substring matching can still misread some texts. See Troubleshooting.
Severity rules¶
classify() in licenses.py checks these groups in this order. The first match decides.
| Group | Licenses (as in the code) | app |
service |
private |
|---|---|---|---|---|
| Network copyleft | AGPL-3.0, -only, -or-later |
blocker | blocker | obligation |
| Strong copyleft | GPL-2.0, GPL-3.0 and their -only / -or-later forms |
blocker | ok | ok |
| Weak copyleft | LGPL-2.1, LGPL-3.0, MPL-2.0, EPL-2.0, CDDL-1.0 (+ some -only forms) |
obligation | ok | ok |
| Non-commercial | CC-BY-NC-*, creativeml-openrail-m, flux-1-dev-non-commercial-license, non-commercial (custom) |
blocker | blocker | blocker |
| Needs reading | apple-amlr, apple-ascl, llama2, llama3, llama3.1, llama3.2, gemma, gemma-terms-of-use, openrail, bigscience-openrail-m, stabilityai-ai-community, nvidia-open-model-license, qwen, deepseek, other, unknown, NOASSERTION |
review | review | review |
| Permissive | MIT, Apache-2.0, BSD-2/3-Clause, ISC, Unlicense, 0BSD, Zlib, PSF-2.0, BSL-1.0, MIT-0, CC0-1.0, CC-BY-4.0, CC-BY-3.0, CC-BY-SA-4.0, CC-BY-SA-3.0 |
attribution | attribution | attribution |
| Anything else | review ("Unrecognized license") | review | review |
Copyleft ids are compared upper-cased with _ turned into -, so Hugging Face's
lower-case agpl-3.0 matches. The non-commercial and needs-reading lists are
compared lower-cased.
creativeml-openrail-m sits in the non-commercial group in the code, with the
comment "conditional use restrictions".
The severity levels¶
- blocker: a real obligation in the mode you chose. Causes exit code
1. - obligation: you owe something, usually attribution or keeping a component separable.
- review: a license the tool cannot safely interpret. Not a pass.
- attribution: permissive, keep the notice.
- ok: nothing triggered in this mode (only shown with
--all).
The published rule¶
Before any of the groups above, classify() checks whether the finding is
published and its license is GPL or AGPL. If so, the result is always
obligation with the headline "Copyleft, and your source is already public —
check the label matches", whatever the ship mode.
published is only ever true for code findings, online, when the GitHub API says
the checkout's own repo is not private. The README notes the side effect: most
AGPL upstreams you have cloned are public, so they show as obligation, not
blocker.
Two things follow from the code as written:
-
It is the checkout's repo that is checked, not your own project's repo.
-
A public GPL checkout in
--ship serviceor--ship privatebecomesobligation, where an unpublished one would beok.
Output¶
__main__.py sorts findings by severity, then name. The terminal report prints a
header (path, ship mode, count), one block per finding, a summary line of counts,
and a note when any review findings exist. JSON output is described in
Configuration.
What is not scanned¶
- pip, npm and other package manifests. The
Findingdataclass and docstrings mention apackagekind, but nothing in the code produces it. - Datasets.
- License text inside source file headers.