Integration
ospac is built to be called by other things: a CI job, a scanner further up the chain, an agent. All three commands default to JSON output for that reason.
Exit codes do not reflect the decision
Read this before writing a CI job.
ospac evaluate and ospac check exit 0 even when the answer is deny or incompatible.
The exit code reports whether ospac ran, not what it concluded.
$ ospac evaluate -l GPL-3.0 -d mobile > /dev/null; echo $?
0 # ...and the decision was "deny"
$ ospac check GPL-2.0 Apache-2.0 > /dev/null; echo $?
0 # ...and they are incompatible
A job written as ospac evaluate -l "$LICENSES" -d mobile and nothing more always passes.
Parse the JSON:
ACTION=$(ospac evaluate -l "$LICENSES" -d mobile | jq -r '.result.action')
[ "$ACTION" = "deny" ] && { echo "blocked"; exit 1; }
Non-zero exits are reserved for real failures: an unknown license ID, an unreadable policy, a malformed dataset.
A CI gate that cannot pass by accident
Two things can still make a compliance check pass without meaning much: the decision is ignored (above), or the custom policy failed to load and the bundled default answered instead. This job guards both, and also treats an unanswered case as a failure.
name: License compliance
on: [pull_request]
jobs:
compliance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install ospac
- name: Validate the policy before trusting it
run: ospac policy validate ./compliance-policy.yaml
- name: Evaluate
run: |
set -euo pipefail
RESULT=$(ospac evaluate \
-l "MIT,Apache-2.0,GPL-3.0" \
-d mobile \
-p ./compliance-policy.yaml)
echo "$RESULT" | jq .
# Our policy must be the one that answered.
if [ "$(echo "$RESULT" | jq -r '.using_default_policy')" != "false" ]; then
echo "::error::custom policy was not loaded; the bundled default answered"
exit 1
fi
ACTION=$(echo "$RESULT" | jq -r '.result.action')
if [ "$ACTION" = "deny" ]; then
echo "::error::$(echo "$RESULT" | jq -r '.result.message')"
echo "remediation: $(echo "$RESULT" | jq -r '.result.remediation')"
exit 1
fi
if [ "$ACTION" = "flag_for_review" ]; then
echo "::warning::needs legal review"
fi
- name: Prove the policy still bites
run: |
# Rules going inert now surfaces as flag_for_review rather than a silent pass,
# but asserting on a case that must be denied still catches it soonest.
ACTION=$(ospac evaluate -l GPL-3.0 -d mobile -p ./compliance-policy.yaml \
| jq -r '.result.action')
if [ "$ACTION" != "deny" ]; then
echo "::error::GPL-3.0 was not denied, policy rules are not matching"
exit 1
fi
That last step is worth keeping even though the fail-safe default now covers the same
ground. It fails on the specific rule you care about rather than on a generic review
request, which is a much faster diagnosis. See
Policies for why rules go inert. Most often a when clause
naming a context field that is not populated.
Reporting into a pull request
-o markdown renders a result for a PR comment or job summary:
ospac evaluate -l "$LICENSES" -d commercial -o markdown >> "$GITHUB_STEP_SUMMARY"
ospac obligations -l "$LICENSES" -f markdown >> "$GITHUB_STEP_SUMMARY"
The SEMCL.ONE toolchain
ospac evaluates licenses but does not discover them. Finding out what is in a project is the job of the neighbouring tools:
| Tool | Role |
|---|---|
| osslili | Detects licenses and copyright in source trees |
| upmex | Extracts declared metadata from package files |
| ospac | Decides whether the result is acceptable under policy |
They compose over JSON on the command line, which is the integration path least likely to break:
# What licence does this package declare, and may we ship it in a mobile app?
LICENSES=$(upmex extract gson-2.10.1.jar \
| jq -r '[.licensing.declared_licenses[].spdx_id] | join(",")')
ospac evaluate -l "$LICENSES" -d mobile
{
"licenses": ["Apache-2.0"],
"distribution": "mobile",
"result": {
"rule_id": "aggregate",
"action": "approve",
"severity": "info",
"message": "Evaluated 1 rules",
"requirements": [
"Preserve copyright and NOTICE file if present",
"Include Apache 2.0 license text",
"State changes made to the code"
],
"remediation": null
},
"using_default_policy": true
}
Note that approve still carries requirements. Permission to ship is not the same as
having nothing to do. Feed those into your NOTICE file.
Install the neighbours with the extra:
pip install "ospac[semcl]"
Their Python APIs are upmex.PackageExtractor and osslili.LicenseCopyrightDetector.
Consult each project’s own documentation for those; the CLI-and-JSON path above is the
stable contract between the tools.
MCP
The SEMCL.ONE MCP server exposes this toolchain to agents, so a model can run a compliance check rather than guess at licence rules. The ospac-backed tools include:
| Tool | Purpose |
|---|---|
run_compliance_check |
End-to-end check over a path |
check_license_compatibility |
Whether two licences can be combined |
validate_policy |
Whether a set of licences passes for a distribution type |
get_license_obligations |
Obligations for a licence |
get_license_details |
Full dataset record |
generate_legal_notices |
NOTICE file content |
JSON-first output is what makes this work: the same structures documented here are what the agent receives.
Docker
The dataset ships in the wheel, so an image needs no data step and no network at runtime:
FROM python:3.12-slim
RUN pip install --no-cache-dir ospac
COPY compliance-policy.yaml /policy.yaml
ENTRYPOINT ["ospac"]
docker build -t ospac .
docker run --rm ospac evaluate -l "MIT,GPL-3.0" -d mobile -p /policy.yaml
Pin the version when the answer needs to be reproducible. Dataset changes ship in patch
releases (see The dataset), so
ospac==1.3.0 fixes both the code and the licence data behind a decision, which matters if
you are keeping compliance evidence.
Pre-commit
repos:
- repo: local
hooks:
- id: ospac-policy
name: Validate compliance policy
entry: ospac policy validate
language: system
files: ^compliance-policy\.ya?ml$
Consuming the JSON
The fields worth building on, all present in evaluate output:
| Field | Use |
|---|---|
result.action |
The decision: approve, deny, or flag_for_review. Also allow when a matched rule states no action of its own |
result.severity |
error, warning, info |
result.message |
Why |
result.remediation |
What to do instead, on a denial |
result.requirements |
Obligations attached to the decision |
using_default_policy |
Whether your policy or the bundled default answered |
evaluate also returns a per_license map with each license’s own action, so a review or
denial can be attributed to the license that caused it rather than to the set.
check returns compatible plus requires_review, violations and warnings.
compatible: false with requires_review: true means a human needs to look, not that a
conflict is known; a warning is added when a license id does not resolve in the dataset,
so a typo cannot read as clean compatibility. When no conflict rule matches, check
answers “no known conflicts” rather than review, so a license is always compatible with
itself. obligations -f json returns
the full licence records under license_data, whose schema is in
The dataset.
An evaluation that matches no rule returns flag_for_review, not allow. A policy with no
rule for a case has not approved it, it has no answer, so ospac surfaces that rather than
permitting it. Handle all four values explicitly:
RESULT=$(ospac evaluate -l "$LICENSES" -d "$DIST" -p ./policy.yaml)
case "$(echo "$RESULT" | jq -r '.result.action')" in
deny) echo "blocked"; exit 1 ;;
flag_for_review) echo "needs review"; exit 1 ;;
approve) echo "ok" ;;
allow) echo "permitted by a rule that states no action" ;;
esac
Whether flag_for_review should fail the build is yours to decide. Failing is the safer
default, since it covers both “legal must look at this” and “the policy does not mention
this case”. If you let it pass, log it somewhere a human reads, otherwise a policy that has
drifted out of coverage becomes invisible again.
Before 1.4.0 an unmatched evaluation returned
allow, so a CI job checking only fordenywould pass on a policy whose rules had stopped matching entirely. If you wrote such a job against an earlier version, uncovered cases now arrive asflag_for_review.