Repository navigation
460 lines (406 loc) · 18.2 KB
/
Copy pathci.yml
File metadata and controls
460 lines (406 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
name: CI
# Four ecosystems live in this repo and each has to stand on its own:
#
# interlock/ the Python package (dependency-free; `dependencies = []`)
# rust/ the OPTIONAL native accelerator, `interlock_scopes`
# js/ the Node/TypeScript client, `interlock-guard`
# web/ the console UI (React/Vite), served by `interlock.console`
#
# The Rust crate is the interesting one to get right. It is optional by
# contract -- `interlock/policy/scopes.py` falls back to the pure-Python tree
# when the import fails -- so the two backends must be exercised SEPARATELY:
# the `python` job proves the fallback path works with no native module
# present, and the `rust` job proves the native path resolves identically.
# Collapsing them into one job would let a broken accelerator hide behind the
# fallback, which is exactly the regression the `interlock_scopes` crate
# exists to be caught for.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Pin the runner image: `ubuntu-latest` moved to 26.04 on 2026-10-28, where
# bubblewrap reports SIGXCPU as exit 152 instead of -24, the egress-grant
# controls exit 1, and setup-python has no 3.9 build. Move deliberately once
# the brace tests handle that image; do not float the image under the suite.
jobs:
python:
name: python ${{ matrix.python-version }}
runs-on: ubuntu-24.04
strategy:
# One failing interpreter must not cancel the others: a 3.9-only
# regression is a different bug from a 3.13-only one.
fail-fast: false
matrix:
python-version: ["3.9", "3.11", "3.13"]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- name: Install
run: |
python -m pip install --upgrade pip
# [dev] pulls pytest AND langchain-core, which is load-bearing here:
# tests/integration/test_langchain_exfil.py calls
# pytest.importorskip("langchain_core"), so WITHOUT the extra that
# test silently skips and the LangChain interceptor goes untested.
python -m pip install -e ".[dev]"
- name: Assert the pure-Python fallback is what this job tested
# With no accelerator installed the package must select PyScopeTree.
# If this ever reports "rust", the native crate leaked into the
# environment and the whole suite below would be testing the other
# backend than the job name claims.
run: |
python - <<'PY'
from interlock.policy import SCOPE_BACKEND
assert SCOPE_BACKEND == "python", (
f"expected the pure-Python backend, got {SCOPE_BACKEND!r}"
)
print("scope backend:", SCOPE_BACKEND)
PY
- name: Test
# Integration tests self-gate: rows needing a kernel sandbox backend
# skip by row, so skips here are expected and not a failure.
run: python -m pytest -q
- name: Detector catalog is current
# docs/reference/detectors.md is generated from the detector modules.
# A detector added, renamed, or rescoped without regenerating the page
# fails here instead of shipping a stale catalog.
run: python scripts/gen_detector_catalog.py --check
rust:
name: rust accelerator
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: Install the package and its test extras
# Same extras as the python job, so this job runs the FULL suite and
# the only variable between the two is the presence of the native
# module. Installing pytest alone here would make the LangChain test
# skip, and the comparison would be against a smaller suite.
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
python -m pip install maturin
- name: Test the engine crate on its own
# interlock_engine has no PyO3 dependency, so its unit tests need no
# Python. The extension crate is deliberately left out: it is built
# with extension-module, which does not link under `cargo test`.
run: cargo test --manifest-path engine/Cargo.toml
working-directory: rust
- name: C ABI smoke from a non-Python caller
# Builds rust/ffi (a cdylib with no PyO3) and compiles smoke.c against
# it with the system compiler. This is the proof that il_eval_plan is
# usable outside CPython; the Python extension cannot serve it because
# extension-module leaves CPython symbols unresolved. Runs before
# pytest so the ctypes check finds the library too.
run: bash scripts/check_native_abi.sh
- name: Build and install the native accelerator
run: |
maturin build --release --out /tmp/wheels
python -m pip install /tmp/wheels/*.whl
working-directory: rust
- name: Assert the native backend is active
run: |
python - <<'PY'
import interlock_scopes
from interlock.policy import SCOPE_BACKEND
assert SCOPE_BACKEND == "rust", (
f"the wheel installed but the package still selected {SCOPE_BACKEND!r}"
)
print("interlock_scopes:", interlock_scopes.__file__)
print("scope backend:", SCOPE_BACKEND)
PY
- name: Native policy evaluator handle lifecycle and artifact parity
# The C ABI smoke above already built rust/ffi, so the ctypes handle
# tests find the library. The artifact parity half compares a native
# documents base with the Python engine over the shipped corpus.
run: python -m pytest -q tests/test_native_handle.py tests/test_native_artifact.py
- name: Native evaluator budget gate
# Committed measurements, gated on the env var: native p99 < 5 ms and
# Python p99 < 10 ms at a 1 MiB artifact / 32 KiB event.
env:
IL_EVAL_BENCH: "1"
run: python -m pytest -q tests/test_native_budget.py
- name: Test
# The FULL suite, not just tests/test_scope_backend.py. The parity
# assertions (test_native_matches_python and friends) fire the moment
# the native module is importable, and running everything on top of
# the native tree proves the rest of the package does not quietly
# depend on a pure-Python-only behaviour.
run: python -m pytest -q
node:
name: node ${{ matrix.node-version }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
# 20 is the floor package.json declares in `engines`; 22 and 24 are
# the LTS lines people actually run the client on. Testing the floor
# is the point -- it is the version a claim can quietly stop being
# true for.
node-version: ["20", "22", "24"]
defaults:
run:
working-directory: js
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: npm
cache-dependency-path: js/package-lock.json
- run: npm ci
- name: Test
# `npm test` compiles src/ + test/ with tsconfig.test.json and runs
# the result under node --test; the compile is half the gate, since
# the tsconfig is strict (noUnusedLocals included).
#
# The test glob in package.json is deliberately UNQUOTED so the shell
# expands it. Quoting it hands the pattern to Node, which only learned
# to glob a --test argument in v21 -- on Node 20 that is a hard
# failure ("Could not find ...") rather than a run. Shell expansion
# produces the same 10 files on every version. Do not quote it back.
run: npm test
- name: Build the published artifacts
# Separate from the test compile: this is tsconfig.build.json, i.e.
# what actually ships in `files: ["dist"]`. A type error that only
# shows up under src-only rootDir belongs to the release, not the test.
run: npm run build
web:
name: web console
runs-on: ubuntu-24.04
# The console ships as a prebuilt bundle inside the Python package
# (`web/dist/` is built at release time), so a type error or a failing
# component test reaches users exactly like a Python bug does. Typecheck,
# test and build are separate steps on purpose: each one names which half
# of the contract broke.
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm test
- name: Build the shipped bundle
run: npm run build
cloud:
name: cloud control plane
runs-on: ubuntu-24.04
# The cloud extras (psycopg, cryptography, webauthn) are optional by
# contract, so they get their own job: the `python` job above proves the
# dependency-free import path still works, and this one proves the Postgres
# store, RLS and the OIDC/step-up code paths actually run.
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: interlock_cloud
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
# A non-owner, NOBYPASSRLS app role: connecting as the table owner or as
# a superuser would silently bypass row-level security and make the
# cross-org tests prove nothing.
IL_CLOUD_TEST_DSN: postgresql://interlock_app:interlock_app@127.0.0.1:5432/interlock_cloud
IL_CLOUD_ADMIN_DSN: postgresql://postgres:postgres@127.0.0.1:5432/interlock_cloud
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install the package with the cloud extras
run: |
python -m pip install --upgrade pip
python -m pip install -e '.[dev,cloud]'
- name: Provision the application role
run: |
psql "$IL_CLOUD_ADMIN_DSN" -v ON_ERROR_STOP=1 <<'SQL'
CREATE ROLE interlock_app LOGIN PASSWORD 'interlock_app'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
SQL
- name: Apply the migrations as the owner
# The app role deliberately cannot create tables (and must not own
# them: a table owner is exempt from its own RLS policy). The step
# keeps the documented command shape and swaps in the owner DSN.
env:
IL_CLOUD_TEST_DSN: ${{ env.IL_CLOUD_ADMIN_DSN }}
run: python3 -m interlock.cloud.migrate --dsn "$IL_CLOUD_TEST_DSN"
- name: Grant the application role the table privileges it needs
# Ownership stays with postgres so RLS applies to interlock_app.
run: |
psql "$IL_CLOUD_ADMIN_DSN" -v ON_ERROR_STOP=1 <<'SQL'
GRANT USAGE ON SCHEMA public TO interlock_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public
TO interlock_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO interlock_app;
SQL
- name: Assert row-level security applies to the application role
# A superuser or table owner would pass this by bypassing the policy.
# The check runs inside one transaction, exactly as the store does.
run: |
python - <<'PY'
import psycopg
dsn = __import__("os").environ["IL_CLOUD_TEST_DSN"]
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute("SELECT rolsuper, rolbypassrls FROM pg_roles "
"WHERE rolname = current_user")
superuser, bypassrls = cur.fetchone()
assert not superuser and not bypassrls, (superuser, bypassrls)
cur.execute("SET LOCAL interlock.org_id = 'no-such-org'")
cur.execute("SELECT count(*) FROM users")
assert cur.fetchone()[0] == 0
print("RLS applies to interlock_app")
PY
- name: Test
# Every cloud file, not a hand-kept list: the C1 seven, the C2
# enrollment/connector/spool files and the C3 review/grant/UI files.
# This is the only job that installs ``.[dev,cloud]``, so it is the only
# place the preview-encryption and WebAuthn paths actually execute — the
# Python matrix jobs skip those files. The Postgres cases here are
# unlocked by IL_CLOUD_TEST_DSN; the store-contract file is the same
# assertions as the in-memory store, so a divergence between the two
# implementations fails here.
run: |
python3 -m pytest -q \
tests/test_cloud_*.py \
tests/test_reviews_grants.py \
tests/test_cloud_receipt_stamp.py
cloud-supply-chain:
name: cloud supply chain
runs-on: ubuntu-24.04
# The control plane is the one deployment where a compromised dependency
# runs with tenant data. This job audits the cloud extra, records its
# resolved dependency graph, and asserts the extra stays range-pinned so
# an unreviewed major bump cannot arrive on its own.
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install the cloud extra and the audit tools
run: |
python -m pip install --upgrade pip
python -m pip install -e '.[cloud]'
# Bounded tool ranges: an unbounded tool can change its exit codes or
# output format between runs and turn a green build red on a release
# the repository did not make.
python -m pip install 'pip-audit>=2.7,<3' 'cyclonedx-bom>=4,<7'
- name: Every cloud extra is pinned to a compatible range
run: |
python - <<'PY'
import tomllib
from pathlib import Path
project = tomllib.loads(Path("pyproject.toml").read_text())["project"]
cloud = project["optional-dependencies"]["cloud"]
ccloud = [item for item in cloud if item]
assert ccloud, "the cloud extra is empty"
for requirement in ccloud:
# A lower bound or an exact pin is fine; a requirement with
# only a lower bound can resolve to an unreviewed major.
assert "<" in requirement or "==" in requirement, (
f"cloud extra {requirement!r} has no upper bound")
print("cloud extra is range-pinned:", ", ".join(ccloud))
PY
- name: Audit the resolved environment
# --skip-editable keeps the local package (not on an index) out of the
# audit; it audits every dependency the cloud extra pulled in.
run: pip-audit --skip-editable
- name: Generate a CycloneDX SBOM
run: |
python -m pip freeze > requirements-cloud.txt
# The requirements file is positional. `-i` is pip's --index-url, so
# passing a filename there makes the tool read ./requirements.txt.
cyclonedx-py requirements requirements-cloud.txt --of JSON -o sbom.cdx.json
- name: Upload the SBOM
uses: actions/upload-artifact@v4
with:
name: cloud-sbom
path: sbom.cdx.json
retention-days: 30
lint:
name: lint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install ruff
# Pinned: an unpinned linter turns a green repo red on a release
# schedule, which is noise, not signal.
run: python -m pip install "ruff==0.16.9"
- name: Lint the shipped package
# Deliberately E9+F only. The repo carries NO lint config anywhere
# (no ruff/flake8/mypy/pyright/tox/setup.cfg), so this gate is scoped
# to the rules that are already satisfied today: syntax errors (E9)
# and pyflakes (F). A broader --select E,F,W reports 25 findings in
# interlock/ and would make the first run red for style, which is not
# what a CI gate is for. Tighten the set as the noise is paid down.
run: ruff check --select E9,F interlock/
ci-ok:
name: ci-ok
# The single branch-protection check. `needs` alone would skip this job
# when a dependency fails, and a SKIPPED required check reads as green on
# some branch-protection configurations, so the `if: always()` plus the
# explicit result test is what makes this job meaningful.
if: always()
needs: [python, rust, node, web, cloud, cloud-supply-chain, lint]
runs-on: ubuntu-24.04
steps:
- name: Check every job succeeded
run: |
echo "python: ${{ needs.python.result }}"
echo "rust: ${{ needs.rust.result }}"
echo "node: ${{ needs.node.result }}"
echo "web: ${{ needs.web.result }}"
echo "cloud: ${{ needs.cloud.result }}"
echo "supply: ${{ needs.cloud-supply-chain.result }}"
echo "lint: ${{ needs.lint.result }}"
for result in \
"${{ needs.python.result }}" \
"${{ needs.rust.result }}" \
"${{ needs.node.result }}" \
"${{ needs.web.result }}" \
"${{ needs.cloud.result }}" \
"${{ needs.cloud-supply-chain.result }}" \
"${{ needs.lint.result }}"
do
if [ "$result" != "success" ]; then
echo "::error::a required job did not succeed (${result})"
exit 1
fi
done