Skip to main content

kiff-scan

What can your AI agent do without asking?

kiff-scan finds the places where a model-controlled parameter reaches an action that deletes, deploys, moves money, rotates a credential, or shifts traffic — and reports whether anything on that path can refuse it.

It parses Python. Other languages are reported as unsupported, never silently treated as clean.

Zero dependencies. No network code. It never executes the code it reads.

uvx kiff-scan scan .

No install, no account, no config.


The output

Run against a sample SRE agent (tests/fixtures/ungoverned_ops.py in this repo):

kiff-scan · what can this agent do without a decision?

  YOUR AGENT'S BLAST RADIUS

  Consequential capabilities: 7
    Decision found on path:   0
    Review required:          7

  Your agent can reach 7 consequential actions with no
  recognised decision on the path.

    Data loss             1   drop_database
    Compute teardown      1   terminate_workers
    Traffic / failover    1   failover_region
    Deploy / rollback     1   rollback_release
    Secrets / identity    1   rotate_db_credentials
    Money movement        1   issue_refund
    Shell / execution     1   run_maintenance

  Most exposed: ungoverned_ops.py:22  drop_database()
    Reachable by:            @tool
    Consequence:             Data loss  (calls delete_db_instance())
    Severity:                high
    Match confidence:        call
    Decision on path:        none found on the analysed path
    Model-controlled inputs: database_id, skip_final_snapshot

    State-dependent: an authorization check is necessary but NOT
    sufficient here.
    Dropping or deleting stored data is irreversible, and whether a given
    target is safe to drop depends on whether it is live.

  7 findings in 1 file  (review required: 5 high, 2 medium, 0 low)

  What this scan did not establish:
    This scan established that a model-controlled parameter reaches a
    consequential call with no recognised decision on the analysed path.
    It did NOT establish that the action is externally reachable, that no
    guard exists elsewhere, that exploitation is practical, or that any
    finding is a vulnerability. A clean scan means no supported path was
    identified -- not that the code is safe.

  Next:
    kiff-scan explain ungoverned_ops.py:22

The idea: an authorization check is not always enough

Most tools in this space ask one question — is there an auth check on this path? That question is necessary, and treating it as sufficient is how a system that looks governed still destroys something.

Consider four actions that pass every authorization check you can write:

  • a rollback to a revision that is itself broken
  • a failover to the region you already failed over to
  • a DROP against the live database rather than the drained replica
  • a refund issued twice because the first has not settled

Each is legitimate in one state and catastrophic in another. No role check, signed plan, or API key can tell them apart, because the difference is not who is asking or what they are asking for — it is when. Only a decision evaluated against live state can refuse them.

kiff-scan marks these categories state_dependent and says so in the report, because "add an authorization check" is the wrong remediation for them and following it produces false confidence.

EXECUTION — arbitrary shell reachable from model input — is marked not state-dependent, because there an auth check plus strict input handling really does close it. The distinction is the point.

Evidence, not verdicts

Every judgement is reported with the evidence that produced it, so you can disagree with the scanner. This matters most where a file mixes governed and ungoverned code:

  Most exposed: mixed_governance.py:37  drop_production()
    Decision on path:        none found on the analysed path

  1 action has a guard call that runs *after* the
  consequential call, so it cannot have gated it:
    ! delete_backups       authorize() appears at line 50, after the sink

  2 actions cleared, with evidence:
    ok drop_replica         DATA_LOSS          decide() before the sink
    ok scale_cluster        DEPLOYMENT         @requires_approval wraps the function

drop_production and drop_replica live in the same file, one line apart in structure, and get opposite verdicts. Governance is judged per function, with lexical precedence, and a guard that runs after the destructive call is reported rather than credited.

Evidence levels: decorator, call_before_sink, module_hook (coarse — labelled as such), call_after_sink (reported), none (reported).

Thin wrappers do not hide the action

Agent tools usually delegate. A scanner that stops at the tool body reports clean on this, and the function's own name gives nothing away:

def _perform(target):
    boto3.client("rds").delete_db_instance(DBInstanceIdentifier=target)

@tool
def handle_request(target: str):
    "Process an operations request."
    return _perform(target)
  Most exposed: fn6_neutral.py:7  handle_request()
    Consequence:             Data loss  (calls _perform() which calls delete_db_instance())
    Match confidence:        call

Calls to functions in the same module are followed up to four hops, the chain is printed, and cycles terminate. A guard found inside the chain is credited, so moving your authorize() into the helper does not create a false finding. Cross-module calls are not followed — see the limitations below.

Guard detection is vendor-neutral

Your own authorize() clears a finding exactly as anything else does. About 30 common authorization and approval names are recognised out of the box — authorize, check_permission, require_role, has_permission, opa_eval, casbin_enforce, cedar_authorize, validate_jwt, and so on. Add yours:

kiff-scan scan . --guard acme_authorize --tool-decorator my_framework_action

or in .kiff-scan.json:

{
  "guards": ["acme_authorize", "company_can"],
  "tool_decorators": ["my_framework_action"],
  "exclude": ["tests/fixtures/**"]
}

Your code does not leave your machine

There is nothing to encrypt, because nothing is transmitted.

Property How it is enforced
No network code anywhere A test rejects any import of socket, ssl, http, urllib, requests, httpx, aiohttp, … in every module
No telemetry Same gate, plus a check for analytics SDK names
No socket opened during a scan The socket constructor is replaced with one that fails the test, then a real scan runs
Works with networking off A CI job drops all outbound traffic and runs the suite
Never executes your code A test plants a file that would create a sentinel on import; the sentinel must not exist after scanning
Read-only A test intercepts open() and fails on any write during a scan
Zero runtime dependencies A test reads pyproject.toml; CI installs the wheel into a clean venv and fails if anything else appears

These run on every push, every pull request, and daily. They are checked properties, not promises — if one stops being true the build goes red.

Zero dependencies is the load-bearing one: a dependency tree is the usual way a developer tool gains the ability to phone home, and you cannot audit one package, you audit all of them forever. Full detail in docs/THREAT_MODEL.md.

Reports contain file paths, line numbers, and parameter names from signatures — never argument values or source text. A test enforces that.

Install

uvx kiff-scan scan .          # no install
pipx install kiff-scan        # or a persistent CLI
pip install kiff-scan         # or into a venv

Python 3.10+. Nothing else.

CI

name: kiff-scan
on: [pull_request]

permissions:
  pull-requests: write     # sticky comment
  security-events: write   # SARIF upload
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: kiff/kiff-scan@v0.1.0

Posts a sticky pull request comment, uploads SARIF to your Security tab, and does not fail the build by default. Once you have triaged a baseline:

      - uses: kiff/kiff-scan@v0.1.0
        with:
          fail-on: high

Pin the exact tag. A moving v1 tag will be published once the interfaces stop changing; until then a floating reference would silently change behaviour under you.

Exit codes

Code Meaning
0 No findings at or above --fail-on
1 Findings at or above --fail-on
2 Usage error (bad arguments, missing path, unusable config)

The CLI defaults to --fail-on medium; the Action defaults to none. That difference is deliberate. The CLI's exit code is its only machine-readable signal, and a scanner that reports seven unguarded money-moving actions and exits 0 produces exactly the false assurance this tool exists to remove. The Action has its own reporting surface, so it stays soft while a team triages.

Commands

kiff-scan scan .                          # scan (also: kiff-scan .)
kiff-scan explain app/tools.py:47         # the analysed path behind one finding
kiff-scan scan . --format json            # machine-readable
kiff-scan scan . --format sarif           # GitHub code scanning
kiff-scan scan . --format markdown        # paste into an issue or PR
kiff-scan scan . --show-unsupported       # list files that could not be analysed
kiff-scan scan . --output report.json     # write to a file

What it does not establish

A clean scan means no supported path was identified — not that your code is safe. Specifically, kiff-scan does not establish that:

  • an attacker can externally reach the agent
  • no guard exists outside the analysed function
  • exploitation is practical, or the operation irreversible
  • any finding is a vulnerability

And it does not look at runtime behaviour, network configuration, prompt injection, or any language other than Python. Decision detection uses lexical precedence, not control flow, so a guard inside if not force: is credited even though a caller can skip it; a guard is recognised by name, not by behaviour, so an authorize() that always returns True clears a finding; and calls are followed only within a module, so a destructive call one import away is missed. Every limitation is listed in docs/COVERAGE.md, including the ones we have not fixed.

Found a case where it is wrong?

That is the most useful bug report this project can get. In scope: a sink it misses, a recognised guard it fails to honour, a finding on code with no model-controlled input, or any case where it reports clean when it should not. Accepted cases become permanent test fixtures, credited by handle. Known misses stay on the list even before they are fixed — a challenge list showing only fixed cases is a trophy cabinet.

Where this comes from

kiff-scan is the diagnostic half of KIFF: the scanner reads the code, the runtime guard listens at execution. It is a standalone tool and depends on nothing from KIFF — no account, no runtime, no adoption. Use it and stop using it without touching anything else.

For a state-dependent finding, the remediation is a decision boundary that evaluates live state before the action runs, and KIFF is one way to build that. Your own state-aware check is another, and kiff-scan will recognise it.

License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

kiff_scan-0.1.0.tar.gz (50.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

kiff_scan-0.1.0-py3-none-any.whl (35.8 kB view details)

Uploaded Python 3

File details

Details for the file kiff_scan-0.1.0.tar.gz.

File metadata

  • Download URL: kiff_scan-0.1.0.tar.gz
  • Upload date:
  • Size: 50.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for kiff_scan-0.1.0.tar.gz
Algorithm Hash digest
SHA256 12bb35d3ba85507ccf43a0557be8be3cb8e02826432d3b20627bc925ca3073d6
MD5 80db68e3e5a219b7179a77e45633ca62
BLAKE2b-256 a48d399a007aa7e7e2f1a54bbf033a351800986515236b6fc63f89cda1fed4ac

See more details on using hashes here.

Provenance

The following attestation bundles were made for kiff_scan-0.1.0.tar.gz:

Publisher: release.yml on kiff/kiff-scan

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file kiff_scan-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: kiff_scan-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 35.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for kiff_scan-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 472ec9cc9eeca7220780d300d6e65fae5d13e83bdc3399b8acd13ad734ae6d59
MD5 28f1b168ecdfb43dd4ca047ba41fbd53
BLAKE2b-256 d5fc12e760270465171d321b41b2ed5f4c4a6f3367670e4443e341f4683fae3b

See more details on using hashes here.

Provenance

The following attestation bundles were made for kiff_scan-0.1.0-py3-none-any.whl:

Publisher: release.yml on kiff/kiff-scan

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.0

2 files

0.1.1

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page