Leak, Injection & Simplicity Auditor.
Lisa is a GitHub Action that reviews every pull request with TypeSafe's Jev model and fails the check when it finds a problem. It asks four yes/no questions about each part of the diff, and compares files that look duplicated:
| Check | Question | Catches |
|---|---|---|
secret |
Does this diff add a secret? | API keys, tokens, passwords, private keys, connection strings |
security |
Does this diff introduce a security vulnerability? | Injection, XSS, weakened access control, unsafe eval or deserialization, path traversal, SSRF, weak crypto, disabled TLS or CSRF checks, permissive config, sensitive data in logs |
complexity |
Does this diff add unneeded complexity? | Single-use abstractions, speculative options, dead code, convoluted control flow, duplication |
prompt_injection |
Does this diff add text that tries to manipulate an AI system? | Instructions to AI reviewers or agents, and hidden text. The PR title and description are checked too. |
duplication |
Do these two files implement the same functionality? | The same logic added in more than one file, such as two diverging copies of a helper. Asked about pairs of files that look alike, not about each chunk. |
A repository can add its own questions, about each part of the diff or about the pull request as a whole, turn checks off, and skip paths in a .lisa.toml file.
For every finding, Lisa comments on the exact line with what is wrong and how to fix it. It also keeps one summary comment on the PR up to date. A maintainer can dismiss a finding they have reviewed and accepted. Lisa runs on the Python standard library alone and installs nothing at run time.
Checks are matched to the kind of file. Every file is checked for secrets and prompt injection. Prose (Markdown, text) gets only those two. Config and data files, and generated code, also get the security check. Only hand-written code gets the quality checks (complexity and duplication).
- Quick start
- Inputs and outputs
- Configuration: .lisa.toml
- Results
- Dismissing a finding
- How it works
- Security
- Limits
- Development
-
Get an API key from the TypeSafe console.
-
Add it to the repository as a secret named
TYPESAFE_API_KEY(Settings > Secrets and variables > Actions). -
Add
.github/workflows/lisa.yml:name: Lisa on: pull_request: jobs: lisa: runs-on: ubuntu-latest timeout-minutes: 30 concurrency: group: lisa-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read pull-requests: write steps: - uses: turenlabs/lisa@<full-commit-sha> # v1 with: api-key: ${{ secrets.TYPESAFE_API_KEY }}
-
In your branch protection rules, make Lisa a required status check.
Notes on this setup:
- No checkout step. Lisa reads the diff through the GitHub API. Keep it in its own job, with no other steps.
- Pin to a commit SHA, not a tag. Tags can be moved to point at different code.
- Public repositories that accept fork PRs: GitHub does not give
pull_requestworkflows from forks access to secrets, so Lisa fails with "Missingapi-key". Useon: pull_request_target:instead. This is safe for Lisa only because it never checks out or runs PR code, so never add a checkout step to that job. Lisa warns if it finds one.
| Input | Required | Default | Description |
|---|---|---|---|
api-key |
yes | TypeSafe API key. Pass it from a secret. | |
threshold |
no | threshold from .lisa.toml, else 0.5 |
Probability (above 0, at most 1) at which Jev's answer counts as "yes". Raise it, for example to 0.8, to flag only clear-cut cases. |
model |
no | jev-latest |
TypeSafe model. Pin a version such as jev-1.13.0 to keep results stable when TypeSafe releases a new model. |
github-token |
no | ${{ github.token }} |
Token for reading the diff and writing comments. Needs pull-requests: write and contents: read. |
| Output | Description |
|---|---|
findings |
Number of findings. |
The exit code is the result: 0 passed, 1 failed. See Results.
Put .lisa.toml in the repository root. It is optional, and so is every setting in it.
# Probability at which an answer counts as "yes". The action's `threshold` input overrides it.
threshold = 0.6
# Paths to skip. `*` matches across directories, so "docs/*" covers everything under docs.
ignore = ["docs/*", "tests/fixtures/*"]
# Allow a PR's author to dismiss findings on their own PR (default: false).
author_can_dismiss = false
# Built-in checks: secret, security, complexity, prompt_injection, duplication.
# `false` turns a check off; a number gives that check its own threshold (see "Per-check thresholds").
[checks]
duplication = false
complexity = 0.7
# Your own questions, asked about every part of the diff (up to 20). See "Question types" below.
[[questions]]
id = "debug-prints" # required: lowercase letters, digits, - or _
question = "Does this diff add print statements used for debugging?" # required
title = "Debug output" # shown in comments; defaults to the id
yes_if = "Adds print() or console.log() calls that look temporary." # optional: what counts as yes
no_if = "Output goes through the project's logger." # optional: what counts as no
why = "Debug output ends up in production logs." # optional: shown to the author
fix = "Remove it or use the logger." # optional: shown to the author
threshold = 0.8 # optional: overrides the threshold for this questionthreshold applies to every built-in check. To make one check stricter or looser without touching the rest, set a number under [checks], such as complexity = 0.7. Findings for that check then need at least that probability to fail the PR. Checks you do not list keep the review-wide threshold.
Custom questions can use any of TypeSafe's three question types. Set type; the default is noul.
type |
Asks | Fails the check when | Type-specific settings |
|---|---|---|---|
noul |
a yes/no question | the probability of "yes" reaches the threshold | yes_if, no_if, threshold (all optional) |
choice |
which one of your options applies | the flagged options' combined probability reaches the threshold | options (2 to 255, required), flag (required), threshold |
score |
where the change falls on your levels | the score reaches fail_at |
levels (2 to 10, best first, required), fail_at (required) |
Every type also takes id, question, title, why, fix, scope, and files. files lists the kinds of file a question is asked about: code, config, prose, and generated (default: all). It applies to scope = "diff" questions.
# choice: fails when "gpl" and "unknown" together are at least 60% likely.
[[questions]]
id = "license"
type = "choice"
question = "Which license does the third-party code added in this diff come under?"
flag = ["gpl", "unknown"]
threshold = 0.6
why = "Copyleft or unlicensed code can change the terms you ship under."
fix = "Replace it with permissively licensed code, or get legal sign-off."
[questions.options]
none = "No third-party code is added."
permissive = "MIT, BSD, Apache, or another permissive license."
gpl = "GPL or another copyleft license."
unknown = "Copied code with no clear license."
# score: levels run from best (0) to worst (2); fails at a score of 1.5 or more.
[[questions]]
id = "tests"
type = "score"
question = "How well do tests in this diff cover the code it adds?"
levels = ["Fully tested", "Partly tested", "Untested"]
fail_at = 1.5
fix = "Add tests for the new behavior."By default (scope = "diff") a question is asked about each chunk of the diff, and Jev sees only that chunk. Some things can only be judged from the whole pull request, such as whether it does more than its title says. Give those questions scope = "pr": they are asked once, and Jev sees the PR title, the description, and a summary of every changed file and directory (largest first) instead of the diff. Any question type can use either scope.
[[questions]]
id = "scope-creep"
scope = "pr"
question = "Does this pull request mix changes with unrelated purposes, beyond what its title and description say it does?"
title = "Mixed changes"
fix = "Split unrelated work into separate pull requests."
[[questions]]
id = "size"
scope = "pr"
type = "score"
question = "How much new code does this pull request add compared with what its stated purpose needs?"
levels = ["About what the purpose needs", "Somewhat more than needed", "Far more than needed"]
fail_at = 1.5
fix = "Cut what the change does not need, or split it up."PR-scope findings appear in the summary comment, since they have no line to comment on.
A choice finding names the flagged option that was most likely, for example License: gpl (70%). A score finding names the level and the score, for example Test coverage: Untested (score 1.8 of 2). Make a choice or score question as narrow as a yes/no one: ask about one property of the change.
- Lisa reads
.lisa.tomlfrom the PR's base commit, never from the PR itself. A pull request cannot turn off the checks that would catch it. Changes to.lisa.tomltake effect once they are merged. - Invalid files fail the check with a message naming the exact problem, such as an unknown setting, a wrong type, or a duplicate question id.
- Write each custom question as one narrow judgment about the diff. Jev is most accurate that way. Split a broad question into two narrow ones.
- Threshold precedence (for
noulandchoice): a question's ownthreshold, then thethresholdinput, thenthresholdin.lisa.toml, then0.5.scorequestions usefail_atinstead.
The check fails when any of these is true:
- Any question's answer fails its check, and no maintainer has dismissed that finding. An answer fails its check when it's a yes (or a flagged choice) at or above the threshold, or a score at or above
fail_at. - Any part of the PR could not be reviewed. That covers a request that failed after retries, a missing or invalid answer, a file over 1 MB whose diff GitHub omits, files beyond GitHub's 3,000-file listing, and a push to the PR during the review.
.lisa.tomlor the inputs are invalid, or the API key is rejected.
Lisa fails closed: a PR passes only when every file that should be reviewed was reviewed.
Comments. The inline comment on a flagged line looks like this:
Security vulnerability: Injection (95% likely)
If any part of the interpolated value can be influenced by a user, they can change the meaning of the query or command and read, modify, or delete data, or run commands on the host.
How to fix: Use parameterized queries or prepared statements, and pass command arguments as a list instead of a shell string. Never build interpreter input with string formatting.
The summary comment contains:
- a table with each check marked Clear or N found;
- a linked list of every finding with its fix;
- the reviewed commit, the model version, and where the settings came from;
- anything that was not reviewed;
- the files skipped by design.
Lisa edits the same summary comment on every push. It does not repeat an inline comment for a problem it has already flagged, even if the line moved. The same summary is written to the job summary.
When a finding is wrong, or the team accepts it, a maintainer resolves Lisa's comment on it. On the next run, Lisa treats that finding as dismissed: it no longer fails the check, and the summary lists it as dismissed, with who dismissed it. Re-run the Lisa job, or push, to update the check.
- Only someone with write access to the repository can dismiss. By default that must be someone other than the PR's author, so the author can't clear their own findings. Set
author_can_dismiss = truein.lisa.tomlto allow it. - A dismissal covers the flagged code as it is. Findings are identified by the content of the flagged line. If that line changes, Lisa reviews it afresh.
- Only findings with an inline comment can be dismissed. Findings about the PR as a whole, or its description, have no comment to resolve.
- If Lisa cannot check dismissals (for example because the GitHub API fails), it dismisses nothing and says so.
- Settings. Validate the inputs, read the PR from the workflow event, and load
.lisa.tomlfrom the base commit. - Files. List the PR's changed files through the GitHub API, then confirm the PR head has not moved. Skip lockfiles, binaries, minified bundles, vendored code, and
ignorepaths, and list them in the summary. Deleted files are reviewed too: removing a security check is a change. When GitHub omits a large file's diff, download both versions (at most 1 MB each) and rebuild the diff. Each reviewed file gets a kind that decides which checks it's asked:prose: Markdown and text;config: JSON, YAML, TOML and similar;generated: markedlinguist-generatedin the base branch's.gitattributes, named like generated code (*.gen.ts,*_pb2.py,*.pb.go,__generated__/), or starting with a marker such as@generatedorDO NOT EDIT;code: everything else.
- Chunks. Split each diff into chunks of at most 12,000 characters and 200 added lines. Jev is most accurate on short, focused input. Nothing is hidden from it:
- long lines are split into segments, not cut off;
- runs of 64 or more spaces are collapsed to
<N spaces>; - invisible Unicode (zero-width, bidi, tag characters) is shown as
<U+XXXX>.
- Questions. Send each chunk to TypeSafe's
/v1/systemoneendpoint: up to 8 requests at a time, with at most 4 checks per request. For each check, the request asks:- the deciding question, which decides pass or fail. For built-in checks this is a Noul (a yes/no probability); custom questions can also be a Choice or a Score;
- a Choice of the kind of problem (for example SQL injection or a hardcoded password). The kind selects the explanation and fix shown to the author. Custom questions skip this and use their own
whyandfix; - a Choice over the chunk's added lines. This picks the line for the inline comment.
- Pull request. Ask the
scope = "pr"questions once, about the title, description, and a summary of every changed file and directory. - Duplicates. Find pairs of files that look alike: the same file name in different directories, or many of the same added lines. Prose and data files are not compared. Jev then compares each pair side by side (up to 20 pairs), and a finding is placed on the second file, naming the first.
- Description. Check the PR title and the whole description for prompt injection.
- Dismissals. Treat findings whose comments a maintainer has resolved as dismissed.
- Publish. Write the summary comment, the inline comments, the job summary, and the
findingsoutput. Exit1on any finding or gap in coverage.
Network errors, rate limits (429), and overloads (529) are retried with exponential backoff, honoring retry-after.
Lisa is a security gate, so it is built to be hard to fool or abuse:
- Everything from the PR is treated as hostile. That covers diffs, file names, the title, the description, and comments. It is sent to Jev as data, rendered in comments only inside escaped code spans, and escaped in every workflow command it prints. A crafted file name cannot add links, mentions, fake headings, or
::commands. - A file's kind never removes security coverage. File names and headers come from the PR author, so calling a file "generated" only turns off the quality checks. A file that claims to be generated still gets the
secret,security, andprompt_injectionchecks. - Dismissals need a second person with write access by default, and last only while the flagged line is unchanged.
- Only bot-authored comments count as Lisa's. Anyone can post Lisa's hidden markers; that cannot hide or replace its comments. The check status, not any comment, is the result.
- Nothing sensitive is logged. Logs contain counts, check names, file paths, and error summaries. They never contain diff content, PR text, API response bodies, or credentials. The API key is never printed, so pass it from a secret; GitHub masks secrets in logs.
- Credentials stay put. HTTPS only. Credentials are never forwarded on a redirect to another host, and redirects away from HTTPS are refused.
- Lisa's code cannot be swapped out. It runs from its own directory with
PYTHONSAFEPATHand without user site-packages, so files in the workspace cannot shadow its modules. It has no runtime dependencies. CI tools are pinned by hash inuv.lock, and CI actions are pinned to commit SHAs. - Jev can only answer questions. Its output is probabilities and option keys, checked against fixed tables. No model text reaches a comment, a command, or a URL.
- Resource use is capped. See Limits.
What Lisa cannot guarantee:
- Jev can be argued with. Text written to talk a model into a "no" can sometimes work. The
prompt_injectioncheck exists to catch that, but a pass is a strong signal, not proof. Keep human review. - Diff content leaves GitHub. Reviewed diff chunks and the PR title and description are sent to TypeSafe. See TypeSafe's data handling policy, and use
ignorefor paths that must not be sent. - Skipped files are not reviewed. That covers lockfiles, binaries, and vendored code; they are listed in the summary instead. Review dependency changes with a dedicated tool such as GitHub's dependency review action.
- Context is limited. Jev judges each chunk on its own, without the rest of the codebase.
- Budget: with
pull_request_targeton a public repository, anyone who opens a PR spends your TypeSafe budget, up to the per-run cap.
| Limit | Value | When exceeded |
|---|---|---|
| Files listed per PR | 3,000 (GitHub's limit) | The check fails; the rest are listed as not reviewed |
| File size when GitHub omits the diff | 1 MB | The check fails; the file is listed as too large |
| TypeSafe requests per run | 5,000 | The check fails before any request is sent |
| Chunk size | 12,000 characters, 200 added lines | The diff is split into more chunks |
| Checks per request | 4 | The checks are split across more requests |
| Custom questions | 20 | .lisa.toml is rejected |
| File pairs compared for duplication | 20, most alike first | The rest are not compared |
Options per choice question, levels per score question |
255, 10 (TypeSafe's limits) | .lisa.toml is rejected |
.lisa.toml size |
64 KB | .lisa.toml is rejected |
| Inline comments per run | 100 | The rest appear only in the summary |
| Attempts per request | 5, waiting at most 60 seconds each | The chunk is marked not reviewed |
| Concurrent requests | 8 |
Requirements: a runner with python3 3.11 or newer. GitHub-hosted runners have it.
The code is organized as follows. Every module in lisa/ has one job:
| Path | Role |
|---|---|
action.yml |
Action definition. Runs python3 -s -m lisa from the action directory. |
lisa/__main__.py |
Entry point. Checks the Python version, then calls review.run. |
lisa/review.py |
Orchestration: the Reviewer class loads settings, collects chunks, asks TypeSafe, and publishes. It owns the fail-closed rules and all logging. |
lisa/models.py |
Every data structure: Chunk, Check, CheckCatalog, Finding, Coverage, ReviewResult, Config, RepoConfig, and others. Data only, plus trivial accessors. |
lisa/default_checks.py |
The five built-in checks, as the searchable DEFAULT_CHECKS catalog, with every kind's explanation and fix. |
lisa/checks.py |
Turns checks into TypeSafe questions and answers into findings. Validates answers. |
lisa/config.py |
Parses and validates the inputs, the workflow event, and .lisa.toml. |
lisa/diff.py |
Parses patches, splits them into chunks, and reveals hidden characters. |
lisa/duplicates.py |
Finds pairs of files that may duplicate each other, for the duplication check. |
lisa/files.py |
Decides how each file is treated: skipped, or reviewed as code, config, prose, or generated code. |
lisa/api.py |
GitHub and TypeSafe clients: retries, size limits, safe redirects, and error messages without bodies. |
lisa/report.py |
Renders the summary and inline comments as markdown. |
lisa/errors.py |
LisaError and its subclasses: ConfigError, ApiError, AuthError. |
tests/ |
One test file per module; test_review.py runs whole reviews against fake GitHub and TypeSafe APIs. |
Commands:
uv run --locked pytest # tests
uv run --locked ruff check lisa tests # lint
uv run --locked ruff format lisa tests # formatThe built-in checks can be looked up and searched:
from lisa.default_checks import DEFAULT_CHECKS
DEFAULT_CHECKS["secret"] # look up by key
[c.key for c in DEFAULT_CHECKS.search("injection")] # ['security', 'prompt_injection']These rules keep Lisa safe. Tests and CI enforce most of them:
- No runtime dependencies. Standard library only. CI fails if
pyproject.tomlgains a dependency. Development tools are pinned inuv.lock; update them withuv lockand commit the lockfile. - Fail closed. Any path that skips, truncates, or cannot parse something must mark it not reviewed (
Coverage.failedortoo_large), which fails the check. It must never be treated as a pass. - No untrusted text in logs. Log counts, check names, file paths, and error summaries only. Print workflow commands only through
review.command. Never put a response body in an exception message. - No untrusted text in comments except through
report.code. - Read settings from the base commit only.
- Data structures go in
models.py, and built-in checks go indefault_checks.py. - Pin CI actions to full commit SHAs, with the version in a comment.
- Add a test for every behavior change. For a security fix, add a test that fails without the fix. The fake TypeSafe in
tests/test_review.pyrejects any request the real API would (tests/typesafe_schema.py); keep that schema in step with TypeSafe's API reference. - A file's kind may only remove quality checks. Never let a file name or header, which the PR author controls, turn off
secret,security, orprompt_injection. - No emojis in code, comments, or output.
To add a built-in check, add a Check to DEFAULT_CHECKS in lisa/default_checks.py. It needs:
- a narrow yes/no
instructions, withtrueandfalsecriteria; kind_instructionsandline_instructions;kindsthat include"other", each with awhyand afix.
tests/test_models.py checks that every kind is complete.
MIT. See LICENSE.
