Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: [main]
branches: [main, dev]
pull_request:

jobs:
Expand Down
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Changelog

All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] — 2026-09-11

Zero-touch adoption: enforcement for tools that never call the MCP server.

### Added

- **Claude Code hook adapter** (`aiitg hook pretooluse` / `aiitg hook posttooluse`): scans a
`Read` target before it enters the model's context and maps the policy decision onto the hook
contract — clean files pass, `dangerous` files are denied with the rule id, trust label and the
exact evidence location, `quarantine` rewrites the read onto a sanitized `.txt` substitute and says
so in `additionalContext`, `human_approval` becomes the native `ask` prompt (optionally mirrored
into the approval queue).
- `aiitg hook-config`: prints the `.claude/settings.json` snippet with an absolute, shell-quoted
executable path. It never writes user configuration.
- `aiitg hook doctor`: self-check for a setup that cannot enforce anything — executable on PATH,
`PreToolUse` hook actually wired in a settings file, quarantine/cache directories writable,
fail-closed default, enforced-format count. Exits 1 when the gateway is not on the path.
A hook command that cannot start is a *non-blocking* error in Claude Code, so without this check a
broken setup looks identical to a working one.
- `(path, mtime_ns, size)` decision cache plus a namespace covering build, sanitizer mode, detector
set and policy rules: a repeat read of an unchanged document costs ~90 ms instead of ~450 ms, and
an upgrade or `--mode` change can never reuse an old verdict.
- `docs/plan-zero-touch-adoption.md`: the design, the measured findings that shaped it, the as-built
notes and the known gaps.

### Security

- The adapter guards the **entire** decision and response-mapping path: any exception or malformed
payload becomes an explicit `deny` with exit code 2 when `fail_closed`, because a non-blocking exit
would let the raw document through.
- Enforced extensions are `parseable ∪ {.doc, .rtf, .odt, .ppt, .docm, .xlsb}`: formats aiitg cannot
parse are denied rather than treated as clean.
- A structurally damaged cache entry is a **miss**, never a verdict, so corruption cannot flip a
clean document to `deny`.
- `PostToolUse` findings are recorded (`POL-TOOL-001`) and reported through `additionalContext`; that
event runs after the tool, so it never emits a permission verdict.

### Fixed

- `PostToolUse` payloads carried `permissionDecision`, a field that event does not honour — the
warning could be dropped or the payload rejected as schema-invalid. Field sets are now per event
and asserted by `TestEventContract`.
- `aiitg hook posttooluse` no longer accepts `--mode`, `--cache-dir`, `--quarantine-dir`,
`--max-file-bytes` or `--queue`, which could never be applied on that event.
- `tests/m2/test_mcp.py` located the MCP server at a hardcoded `.venv/bin/aiitg-mcp`, so the smoke
test could not run in CI.

### Added (project hygiene)

- `SECURITY.md` with an in-scope / out-of-scope threat model, `CONTRIBUTING.md`, this changelog,
project URLs in `pyproject.toml`, and a GitHub Actions workflow (lint, typecheck, tests on Python
3.11 and 3.12).

## [0.1.0] — prior work

Initial line, not released here: format-agnostic document model (`docx`, `xlsx`, `xls`, `pdf`,
`html`, `pptx`), 7 concealment detectors, coordinate-level evidence reports, sanitizer, trust labels,
rule-based policy engine, append-only audit log, human-approval queue and an MCP server. See the
README for the capability tables.
59 changes: 59 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Contributing

Thanks for looking. This project has one invariant above everything else:

> **External content is data, never authority** — and a check that silently does not run is worse
> than no check, because the operator believes it ran.

## Development setup

```bash
git clone https://github.com/sscodeai/aiitg.git
cd aiitg
uv venv .venv && uv pip install -e ".[dev]"
# or: python3.11 -m venv .venv && .venv/bin/pip install -e ".[dev]"
```

## The gates (all three must pass before a PR)

```bash
make test # pytest
make lint # ruff, line-length 120
make typecheck # mypy on src
```

CI runs exactly these on Python 3.11 and 3.12 for every push to `main`/`dev` and every pull request.

## Conventions

- **English only** in code, comments, commit messages, README and docs. Japanese is allowed only in
`README.ja.md` (user-facing translation).
- **Conventional commits**: `feat:`, `fix:`, `docs:`, `test:`, `chore:`, `ci:`, `refactor:` — with a
scope where it helps (`fix(hooks): …`).
- **Split commits by function**, not one big commit per PR.
- **Fixtures are generated in code** (`tests/fixtures/builders.py`), never committed as binaries: a
malicious `.docx` in git is unreviewable, a builder function is diffable.
- **Test counts are enforced.** `tests/test_readme_badge.py` fails if the README badge or the
`make test # N tests` comment disagrees with the number of collected tests, so bump them in the same
commit that adds or removes tests.
- **A new detector or format needs**: a builder in `tests/fixtures/builders.py`, a detector test, and
a format test — plus a note in the README tables.

## Design rules worth keeping

1. **Fail closed on decisions, fail open on availability.** If a document cannot be scanned, deny it.
If the gateway itself is unavailable, do not break the user's tool.
2. **Never a silent substitution.** A `quarantine` decision rewrites what the model reads; the
response must say so (`additionalContext`, audit line).
3. **One place per external contract.** Claude Code hook field names live in
`aiitg/hooks/claude_code.py` (`DOCUMENTED_FIELDS` + `render_outcome`), and
`tests/m3/test_hooks.py::TestEventContract` asserts the emitted field set matches the documented
set per event.
4. **Evidence is coordinate-level.** Every finding carries a location that a human can open.

## Pull requests

- Branch from `dev`, keep `main` as the release line.
- Describe the threat model you are changing, not just the diff: what can reach the model before and
after your change.
- If your change touches a promise in the README, update the README in the same PR.
4 changes: 2 additions & 2 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![CI](https://github.com/sscodeai/aiitg/actions/workflows/ci.yml/badge.svg)](https://github.com/sscodeai/aiitg/actions/workflows/ci.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)]()
[![Tests](https://img.shields.io/badge/tests-152%20passed-brightgreen.svg)](.github/workflows/ci.yml)
[![Tests](https://img.shields.io/badge/tests-191%20passed-brightgreen.svg)](.github/workflows/ci.yml)
[![arXiv](https://img.shields.io/badge/arXiv-2507.06185-red.svg)]()

---
Expand Down Expand Up @@ -176,7 +176,7 @@ else:
## テスト

```bash
make test # 152 tests
make test # 191 tests
make lint # ruff
make typecheck # mypy
make build # wheel/sdist をビルド
Expand Down
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![CI](https://github.com/sscodeai/aiitg/actions/workflows/ci.yml/badge.svg)](https://github.com/sscodeai/aiitg/actions/workflows/ci.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)]()
[![Tests](https://img.shields.io/badge/tests-152%20passed-brightgreen.svg)](.github/workflows/ci.yml)
[![Tests](https://img.shields.io/badge/tests-191%20passed-brightgreen.svg)](.github/workflows/ci.yml)
[![arXiv](https://img.shields.io/badge/arXiv-2507.06185-red.svg)]()

---
Expand Down Expand Up @@ -98,6 +98,27 @@ Untrusted input (docx/xlsx/xls/pdf/html/pptx)

## Install

### Use it (recommended for the Claude Code hook)

The hook runs as a separate process on every enforced tool call, so `aiitg` must be on `PATH` — a
hook command that cannot start is a *non-blocking* error in Claude Code, and the document would pass
through **unscanned**. Install it as a tool, not into a project venv:

```bash
uv tool install git+https://github.com/sscodeai/aiitg
# or
pipx install git+https://github.com/sscodeai/aiitg
```

Then wire it up and verify:

```bash
aiitg hook-config # paste the snippet into .claude/settings.json
aiitg hook doctor # confirm it is wired AND startable
```

### Work on it (development)

```bash
uv venv .venv && uv pip install -e ".[dev]"
# or
Expand Down Expand Up @@ -177,6 +198,19 @@ file through. Add `--audit audit.jsonl` to reuse the shipped append-only audit l
Measured on a docx: a cache miss costs ~450 ms, a cache hit ~90 ms (same as the process-startup floor),
because the `(path, mtime_ns, size)` decision cache short-circuits before the pipeline runs.

**Verify the wiring before trusting it** — a hook command that cannot start is a *non-blocking* error
in Claude Code, so documents would reach the model unscanned while everything looks configured:

```bash
aiitg hook doctor # executable on PATH, hook actually wired, directories writable
```

The decision cache and the audit log are **local state, not a trust boundary** — an attacker with
write access to your home directory can forge a cached verdict, exactly as they could edit your
settings. The cache is keyed on `(path, mtime_ns, size)` plus a namespace covering the build,
sanitizer mode, detector set and policy rules, so an upgrade or a `--mode` change can never reuse an
older verdict; a damaged entry is treated as a miss, never as a decision.

## Library

```python
Expand Down Expand Up @@ -209,7 +243,7 @@ else:
## Testing

```bash
make test # 152 tests
make test # 191 tests
make lint # ruff
make typecheck # mypy
make build # build wheel/sdist
Expand All @@ -231,6 +265,10 @@ Malicious test fixtures are **generated in code** (never committed as binaries)
- [ ] More formats (legacy `.doc`, `.ppt`, images via OCR) · multi-policy engine · distributed audit · benchmark dataset for hidden-injection detection
- [ ] M3.1 — HTTP proxy / reverse-proxy interception for API clients, per-tool `updatedToolOutput` adapters, Bash heuristics

Design notes, measured findings and known gaps for M3 live in
[`docs/plan-zero-touch-adoption.md`](docs/plan-zero-touch-adoption.md). Contribution rules and the
threat model are in [`CONTRIBUTING.md`](CONTRIBUTING.md) and [`SECURITY.md`](SECURITY.md).

## License

MIT
49 changes: 49 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Security Policy

## Supported versions

The `main` branch is the supported release line. Security fixes land on `dev` first and are merged
into `main`; older commits and forks are not maintained.

## Reporting a vulnerability

**Please do not open a public issue for a vulnerability.**

Use GitHub's private reporting flow: **Security → Report a vulnerability**
(<https://github.com/sscodeai/aiitg/security/advisories/new>). Include:

- the affected version (`aiitg version`),
- a minimal reproduction (a generated document, a payload, or the command line),
- what an attacker gains (e.g. hidden content that reaches the model despite `dangerous` evidence,
a policy decision that can be bypassed, a crash path that fails open).

You will get an acknowledgement within a few days and a fix or an explicit "won't fix / by design"
answer. There is no bug bounty.

## What counts as a vulnerability

In scope — anything that breaks the project's promises:

- **Silent non-enforcement**: a path where untrusted content reaches the model while aiitg reports
success (a hook that cannot run, a fail-closed branch that actually allows, a cache entry that
overrides a real verdict).
- **Detection bypass**: a concealment technique that a human reader cannot see but aiitg reports as
`safe` with no evidence, or a sanitized output that still carries the hidden payload.
- **Policy bypass**: reaching `allow` for a document the default policy would block/quarantine, or a
`human_approval` decision that proceeds without a human.
- **Crash-to-allow**: any exception path that results in the document being consumed anyway.
- **Injection in the auditor's own surfaces**: audit-log or report content that can execute or
mislead a downstream tool.

Out of scope (by design — see the README and `docs/plan-zero-touch-adoption.md`):

- Detection is **not** claimed to be complete. A novel concealment technique with no detector is a
feature gap, not a vulnerability — unless aiitg reports the document as `safe` *because* the
technique was concealed (that is in scope).
- An attacker who already has local user-level access: they can read the quarantine directory, forge
cache entries, or edit `.claude/settings.json`. The decision cache and the audit log are local
state, **not** a trust boundary; treat them like any other user-writable file.
- Content the operator deliberately approved (the human-approval queue is a human decision, and
approving reveals the document by design).
- Attacks that require the model to ignore its own instructions (that is model alignment, not aiitg's
trust boundary).
70 changes: 68 additions & 2 deletions docs/plan-zero-touch-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,10 @@ adapters, the HTTP proxy, and any auto-writing of user settings.
## M3.0 as-built

What shipped: `src/aiitg/hooks/{__init__,cache,claude_code}.py`, `src/aiitg/cli/hook_cmd.py`, a
4-line registration diff in `src/aiitg/cli/app.py`, and `tests/m3/`. Total suite: **152 tests
(105 pre-existing + 47 new), 0 failures**; `ruff check src tests` and `mypy src` clean.
4-line registration diff in `src/aiitg/cli/app.py`, and `tests/m3/`. Total suite: **183 tests**,
0 failures; `ruff check src tests` and `mypy src` clean. The count is now enforced by
`tests/test_readme_badge.py`, which fails when the README badge or its `make test # N tests` comment
disagrees with the collected test count (it had gone stale twice).

Deviations from the plan, and why:

Expand All @@ -458,6 +460,70 @@ Deviations from the plan, and why:
`status == "error"` report is cached like any other decision (the report carries the error, so a
repeat read does not re-parse a corrupt file).

Post-review fixes (review found one contract bug and one silent-failure gap):

6. **`render_outcome` is event-aware.** It emitted `permissionDecision` /
`permissionDecisionReason` for *every* event, but the documented per-event table gives
`permissionDecision` to `PreToolUse` and `PreModelSwitch` only — `PostToolUse` uses top-level
`decision: "block"` + `reason`, and its only context field is `additionalContext`. The first
`PostToolUse` payload therefore either had its warning dropped or was rejected as
schema-invalid. The field sets now live in one place (`DOCUMENTED_FIELDS`) and `render_outcome`
renders per event; `tests/m3/test_hooks.py::TestEventContract` asserts the emitted field set is a
subset of the documented set for every outcome kind. The old tests only asserted the absence of
`updatedToolOutput`, which is why a green suite hid the bug.
7. **PostToolUse can no longer emit a verdict at all.** A clean result prints nothing (exit 0), a
finding prints `additionalContext` only, and a malformed payload or internal fault uses the
documented "exit 2 + stderr" channel with no JSON on stdout.
8. **`hook-config` embeds an absolute executable path** (resolved with `shutil.which`, `shlex.quote`d)
and warns on stderr when the executable is not on PATH: a hook command that cannot start is a
*non-blocking* error in Claude Code, so the document would reach the model **unscanned** while the
user believes it was checked. `aiitg hook doctor` checks the executable, the quarantine and cache
directories, the fail-closed default and the enforced-format count, and exits 1 on any failure.
9. **The PostToolUse scan is bounded** (`HookConfig.max_posttooluse_texts`, default 64) so a large
tool result cannot force a full-text scan on every call.

Second review round — what else was wrong (all fixed in this branch):

10. **`aiitg hook posttooluse` accepted options it could not apply** (`--mode`, `--cache-dir`,
`--quarantine-dir`, `--max-file-bytes`, `--queue`). A flag that silently does nothing is the same
failure class as a hook that silently does not run. Only `--audit` and `--fail-open` remain, and
`--audit` now actually records PostToolUse findings (`POL-TOOL-001`, action `allow`, counts in the
note) instead of being ignored.
11. **`hook doctor` did not check that the hook was wired.** It now reads the project and user
`.claude/settings.json` / `settings.local.json` (`--settings` to point elsewhere), finds a
`PreToolUse` command calling `aiitg hook pretooluse`, and verifies that command's executable
resolves — a wired-but-unstartable hook is reported as FAIL rather than looking fine.
12. **Cached verdicts were not bound to the build.** The key is still `(path, mtime_ns, size)`, but
entries now carry a namespace of `version | mode | detector set | policy rules` and a mismatched
namespace is a miss, so an upgrade or `--mode` change cannot keep serving yesterday's verdict for
an unchanged file. (`_cache_namespace`.)
13. **A damaged cache entry could become a `deny`.** `HookCache.get` now validates the entry's
required keys and types and returns a miss for anything structurally incomplete, so corruption
cannot flip a clean document. Verified live: deleting `report` from an entry and re-running still
produced `allow`.
14. **`allow` decisions stored the document's text.** Only a `quarantine` decision needs
`sanitized_text` (to write the substitute), so everything else stores an empty string instead of
copying clean document bodies into the cache directory.
15. **Repository hygiene**: version bumped to `0.2.0` with a `CHANGELOG.md`; `SECURITY.md` (in-scope /
out-of-scope threat model, including "the cache is not a trust boundary") and `CONTRIBUTING.md`;
`[project.urls]` in `pyproject.toml`; README install path for end users (`uv tool install` /
`pipx install`, because the hook needs `aiitg` on `PATH`) and a link to this document.

## Known gaps (M3.0, deliberate)

- No prune/cleanup command for the quarantine and decision-cache directories (they are inert data and
safe to delete, but nothing expires them; the plan's §2.6 "orphan cleanup CLI" is not implemented).
- Repeated reads of the same unchanged document append one audit line per read (correct for an audit
trail, but worth knowing before sizing the log).
- The `decision is None` branch in the adapter is unreachable in practice: `run_scan` already returns
`status == "error"` for unsupported formats, which the pipeline turns into `POL-001` → `block`.
- The cache directory is user-writable, so a local attacker with the same level of access can forge a
verdict. Documented in `SECURITY.md` as out of scope (the cache is not a trust boundary); a
tamper-evident cache would need signing and is not attempted here.
- Still unverified end-to-end: `PostToolUse.updatedToolOutput` rewriting (needs per-tool output
shapes) and anything requiring a live Claude Code session (matcher/`if` resolution, timeouts, the
`ask` prompt rendering, `@`-referenced files).

Measured latency, real console script (`.venv/bin/aiitg hook pretooluse`, 5 runs, docx):

| path | median |
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "aiitg"
version = "0.1.0"
version = "0.2.0"
description = "AI Input Trust Gateway — hidden content auditor for documents fed to LLMs/Agents"
readme = "README.md"
requires-python = ">=3.11"
Expand Down Expand Up @@ -33,6 +33,13 @@ dependencies = [
"xlrd>=2.0",
]

[project.urls]
Homepage = "https://github.com/sscodeai/aiitg"
Repository = "https://github.com/sscodeai/aiitg"
Issues = "https://github.com/sscodeai/aiitg/issues"
Changelog = "https://github.com/sscodeai/aiitg/blob/main/CHANGELOG.md"
Security = "https://github.com/sscodeai/aiitg/blob/main/SECURITY.md"

[project.optional-dependencies]
dev = [
"pytest>=8",
Expand Down
Loading
Loading