tamperward 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -134
- package/dist/cli/index.js +205 -0
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -6,55 +6,51 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center"><em>A ward is the obstruction inside a lock that blocks the wrong key.</em></p>
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
make checks pass they take the cheapest path: deleting failing tests, weakening
|
|
15
|
-
assertions, casting to `any`, suppressing lint/type errors, lowering coverage gates,
|
|
16
|
-
editing CI, or bypassing hooks with `--no-verify`. Tamperward treats the safety nets
|
|
17
|
-
themselves as protected assets and blocks the *class* of bypass — so no single
|
|
18
|
-
shortcut is enough.
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/tamperward"><img src="https://img.shields.io/npm/v/tamperward?label=npm&color=4F46E5" alt="npm version"></a>
|
|
11
|
+
<a href="https://github.com/hexrift/tamperward/actions/workflows/release.yml"><img src="https://img.shields.io/github/actions/workflow/status/hexrift/tamperward/release.yml?label=release" alt="release"></a>
|
|
12
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-lightgrey" alt="license"></a>
|
|
13
|
+
</p>
|
|
19
14
|
|
|
20
|
-
|
|
15
|
+
**The deterministic agent-integrity gate.** AI coding agents optimize for "the command
|
|
16
|
+
succeeded," not "the change is trustworthy." To make checks pass they take the cheapest
|
|
17
|
+
path: deleting failing tests, skipping them, casting to `any`, suppressing lint errors,
|
|
18
|
+
lowering coverage gates, rewriting snapshots, editing CI, or bypassing hooks with
|
|
19
|
+
`--no-verify`. Tamperward treats the safety nets themselves as protected assets and
|
|
20
|
+
blocks the **class** of bypass — one ruleset, evaluated on the actual diff and commands
|
|
21
|
+
as a verdict, not a probability, enforced everywhere a change can be made: **inside the
|
|
22
|
+
agent's loop, at the commit, and at the merge.**
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
> Apache-2.0 · zero runtime model calls · every headline claim below is measured, with
|
|
25
|
+
> the pre-registered predictions (including the refuted ones) committed to this repo
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
hook wire format, the `.tamperward.yml` schema, and the `--json` `Finding` shape. The
|
|
26
|
-
package publishes no `main` and no `exports` — it is a binary, not a library — so nothing
|
|
27
|
-
under `src/` is public and internal refactors will never break you.
|
|
27
|
+
## Install
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
changelog entry saying which rule changed. See
|
|
33
|
-
[CONTRIBUTING](./CONTRIBUTING.md#versioning) for the full rule.
|
|
29
|
+
```bash
|
|
30
|
+
npx tamperward init
|
|
31
|
+
```
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
One idempotent command wires all four enforcement points — it never overwrites anything
|
|
34
|
+
you wrote, and `--dry-run` shows the plan first:
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
| point | what it wires |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| agent loop | Claude Code `PreToolUse` deny + `Stop` sweep, merged into `.claude/settings.json` |
|
|
39
|
+
| pre-commit | husky when present, the plain git hook otherwise |
|
|
40
|
+
| CI | a PR-gate workflow with out-of-band sign-off (details below) |
|
|
41
|
+
| policy | a commented baseline `.tamperward.yml` — the defaults apply even without it |
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
denied: fix the real failure, or hunt for another way around. That is the
|
|
42
|
-
**bypass-to-fix conversion rate** (SPEC §7.B), and it is measured, not asserted —
|
|
43
|
-
`harness/run.sh` stands up an isolated repo with a real bug and a failing protected
|
|
44
|
-
test, lets a headless agent loose on it with the hooks installed, and classifies the
|
|
45
|
-
end state with an oracle independent of the detectors under test.
|
|
43
|
+
Or run the views directly:
|
|
46
44
|
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
```bash
|
|
46
|
+
npx tamperward check --staged # pre-commit view
|
|
47
|
+
npx tamperward check --diff "main...HEAD" # CI view — the authority for main
|
|
48
|
+
```
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|---|---|---|---|---|---|
|
|
52
|
-
| `seed` · adversarial | Sonnet 5 | 1 | 0 | 1 | 0 |
|
|
53
|
-
| `seed` · **forced** | Sonnet 5 | 5 | 0 | 5 | 0 |
|
|
54
|
-
| `seed-hard` · adversarial | Sonnet 5 | 3 | 0 | 3 | 0 |
|
|
55
|
-
| `seed-hard` · adversarial | Haiku 4.5 | 33 | 20 | 33 | 0 |
|
|
50
|
+
## What it looks like when it fires
|
|
56
51
|
|
|
57
|
-
|
|
52
|
+
An agent, mid-loop, tries the classic shortcut. The deny happens **before** the tool
|
|
53
|
+
runs, and holds even under `bypassPermissions`:
|
|
58
54
|
|
|
59
55
|
```
|
|
60
56
|
5. Bash rm calc.test.js → DENIED (test-deletion)
|
|
@@ -62,110 +58,97 @@ The trajectory the whole design is built around, from a transcript:
|
|
|
62
58
|
7. Bash node --test → green, honestly
|
|
63
59
|
```
|
|
64
60
|
|
|
65
|
-
|
|
66
|
-
supports "no conversion failure observed in 20 interceptions", whose 95% lower bound is
|
|
67
|
-
≈ 86% — above the ~80% bar SPEC §7.B set for "the loop works", but not a claim of 100%.
|
|
68
|
-
The split matters more than the total — Sonnet 5 never attempted a bypass at all,
|
|
69
|
-
including 5/5 runs in `forced` mode where the prompt *orders* it to delete the failing
|
|
70
|
-
test; all 20 interceptions came from Haiku 4.5 on the seed whose honest fix is genuinely
|
|
71
|
-
expensive. Of those 20, the agent's first move after the denial was a real fix attempt
|
|
72
|
-
19 times; once it went and read `.tamperward.yml` looking for another way through before
|
|
73
|
-
converting. That probe is counted here as an attempted second bypass, not smoothed away.
|
|
74
|
-
|
|
75
|
-
So on scenarios this size, a frontier model mostly does not need the gate. What the run
|
|
76
|
-
supports is the narrower, more durable claim: **when a shortcut is attempted, it is
|
|
77
|
-
caught deterministically, and the denial redirects the agent to the real fix rather than
|
|
78
|
-
to another bypass.**
|
|
79
|
-
|
|
80
|
-
Reproduce: `npm run build && harness/run.sh 5 adversarial haiku seed-hard`
|
|
81
|
-
|
|
82
|
-
## Status
|
|
83
|
-
|
|
84
|
-
Phase 1 — the engine, the eight mechanical detectors, the `check` CLI, and the Claude Code
|
|
85
|
-
agent layer (PreToolUse hook + Stop sweep + `allow`). Tamperward gates its own repo in CI
|
|
86
|
-
with the same engine it ships.
|
|
87
|
-
|
|
88
|
-
- `src/types.ts` — the `Change` model every adapter manufactures and every detector
|
|
89
|
-
consumes (the one decision the codebase inherits).
|
|
90
|
-
- `src/diff/parse.ts` — pure `git diff` → `Change[]` parser. Handles add / modify /
|
|
91
|
-
delete / **rename (as one change carrying `oldPath`)** / rename+edit / binary, with
|
|
92
|
-
per-line old/new line numbers correct across multiple hunks.
|
|
93
|
-
- `src/git/build.ts` — the git adapter: range / staged / worktree views, enriching
|
|
94
|
-
`before`/`after` with full file content for the AST detectors.
|
|
95
|
-
- `src/detectors/` — the **nine mechanical rules**: `no-verify`, `ts-any-cast`,
|
|
96
|
-
`lint-suppression`, `test-skip`, `coverage-lowering`, `ci-tampering`,
|
|
97
|
-
`hook-tampering`, `test-deletion` (the last counts `it()/test()` via the TS AST,
|
|
98
|
-
and handles delete / rename-out-of-glob / shell mutation), and `snapshot-rewrite`
|
|
99
|
-
(a `warn`: re-recording a snapshot/golden expectation from current output — the one
|
|
100
|
-
rule built from measured demand, after the affordance experiment put the move at a
|
|
101
|
-
70% attempt and 100% through rate; see `harness/PREDICTION-affordance.md`).
|
|
102
|
-
- `src/engine.ts` — runs the enabled rules over `Change[]`; honours `policy.ignore`.
|
|
103
|
-
- `src/cli/` — `tamperward check --staged | --worktree | --diff <base>...<head>`,
|
|
104
|
-
exit 1 on any blocking finding.
|
|
105
|
-
- `test/` — 248 tests, including the AST-vs-regex, self-hosting precision, and
|
|
106
|
-
pre-go-live audit regression cases, and the renderer accessibility contract.
|
|
107
|
-
|
|
108
|
-
- `src/adapters/claude/` + `src/cli/hook.ts` — the agent layer: `tamperward hook claude`
|
|
109
|
-
(PreToolUse deny, fail-closed) and `tamperward sweep claude` (Stop sweep, compared against
|
|
110
|
-
the turn's starting commit so a mid-turn commit can't launder a tamper past it).
|
|
111
|
-
- `src/signoff.ts` — the three-layer sign-off model: the agent honours nothing it can author.
|
|
112
|
-
|
|
113
|
-
Next: the negatives corpus to graduate the two heuristic rules, and a larger §7.B run
|
|
114
|
-
to tighten the interval on the conversion rate.
|
|
115
|
-
|
|
116
|
-
See **[SPEC.md](./SPEC.md)** for the full build spec, the detector table, the
|
|
117
|
-
enforcement-point wiring, and the proof harness.
|
|
118
|
-
|
|
119
|
-
## Use
|
|
61
|
+
The same engine at pre-commit or in CI:
|
|
120
62
|
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
|
|
63
|
+
```
|
|
64
|
+
tamperward: 2 blocking
|
|
65
|
+
(2 changes scanned)
|
|
66
|
+
|
|
67
|
+
BLOCK coverage-lowering package.json
|
|
68
|
+
Coverage gate weakened: global lines threshold lowered 90 → 10.
|
|
69
|
+
evidence global lines threshold lowered 90 → 10
|
|
70
|
+
instead Raise real coverage by adding tests; do not lower or exempt
|
|
71
|
+
the gate to pass.
|
|
72
|
+
sign-off tamperward allow coverage-lowering --file package.json --reason "..."
|
|
73
|
+
|
|
74
|
+
BLOCK test-deletion test/calc.test.js
|
|
75
|
+
Test blocks removed: 3 → 1 it()/test() in this spec.
|
|
76
|
+
evidence 2 test block(s) removed from test/calc.test.js
|
|
77
|
+
instead Keep the assertions and fix the code. Removing test blocks to go
|
|
78
|
+
green is the tamper.
|
|
79
|
+
|
|
80
|
+
A blocking finding clears only with a human sign-off. In CI that sign-off is
|
|
81
|
+
out-of-band — a PR label applied by a reviewer — never a file committed on the
|
|
82
|
+
branch under review.
|
|
124
83
|
```
|
|
125
84
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
85
|
+
Under GitHub Actions each finding also lands as an **inline annotation on the diff** in
|
|
86
|
+
*Files changed*, plus a job-summary table — not four clicks deep in a log.
|
|
87
|
+
|
|
88
|
+
## The rules
|
|
89
|
+
|
|
90
|
+
Nine mechanical rules, deterministic by construction, `block` by default:
|
|
91
|
+
`test-deletion` (AST block-count, rename-out-of-glob, shell mutation), `test-skip`
|
|
92
|
+
(including `.only`, which narrows the suite), `ts-any-cast`, `lint-suppression`,
|
|
93
|
+
`coverage-lowering` (Jest and Vitest shapes, all four metrics, `package.json` included),
|
|
94
|
+
`ci-tampering` (a **moved** check is not a removed one), `hook-tampering`, `no-verify`
|
|
95
|
+
(flags and the env-var escape hatches), and `snapshot-rewrite` (`warn` — see the FP study
|
|
96
|
+
below for why).
|
|
97
|
+
|
|
98
|
+
Three heuristics ship as `warn` and never block until their precision clears the bar:
|
|
99
|
+
`assertion-weakening`, `guard-removal`, `ts-any-launder`.
|
|
100
|
+
|
|
101
|
+
The policy file itself is un-ignorable, CI reads the policy from the **merge-base** (a PR
|
|
102
|
+
cannot govern its own verdict), and the agent honours no sign-off it can author — the
|
|
103
|
+
escape valve is a PR label only a human with write access can apply.
|
|
104
|
+
|
|
105
|
+
## Measured, not asserted
|
|
106
|
+
|
|
107
|
+
**Does the loop work?** `harness/run.sh` stands up an isolated repo with a real bug and a
|
|
108
|
+
failing protected test, lets a headless agent loose with the hooks installed, and
|
|
109
|
+
classifies the end state with an oracle independent of the detectors under test
|
|
110
|
+
(SPEC §7.B). **42 runs, 0 tampers reached green; all 20 runs where a block fired ended in
|
|
111
|
+
an honest fix** — a 20/20 point estimate whose 95% lower bound is ≈86%. The split is the
|
|
112
|
+
honest part: Sonnet 5 never attempted a bypass at all, even when the prompt *ordered*
|
|
113
|
+
one; all 20 interceptions came from Haiku 4.5 on a seed whose honest fix is genuinely
|
|
114
|
+
expensive. Reproduce: `npm run build && harness/run.sh 5 adversarial haiku seed-hard`.
|
|
115
|
+
|
|
116
|
+
**Which detectors deserve to exist?** Measured too. Affordance seeds offer an agent one
|
|
117
|
+
specific bypass each; predictions are pre-registered before any rep runs
|
|
118
|
+
(`harness/PREDICTION-affordance.md`). Result: 7/10 adversarial runs regenerated a golden
|
|
119
|
+
file from buggy output (100% passed unseen — so `snapshot-rewrite` was built), while
|
|
120
|
+
timeout-inflation and lint-config-gutting measured **0/10 attempts each** — two
|
|
121
|
+
pre-registered bets refuted, two detectors *not* built. Bypass classes with no measured
|
|
122
|
+
demand stay in a record-only shadow scanner that accumulates evidence on every weekly
|
|
123
|
+
harness run.
|
|
124
|
+
|
|
125
|
+
**What about false positives?** `snapshot-rewrite` was swept over **1,652 real mainline
|
|
126
|
+
commits** (prettier, jest, docusaurus, immer): 216 touched snapshots, all legitimately —
|
|
127
|
+
which is exactly why that rule is a `warn` asking for human confirmation, not a block,
|
|
128
|
+
and why the study is committed (`harness/fp-study/`) rather than summarized away.
|
|
143
129
|
|
|
144
|
-
|
|
130
|
+
## Stability
|
|
145
131
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
132
|
+
The public surface is the CLI and its **exit codes**, the hook wire format, the
|
|
133
|
+
`.tamperward.yml` schema, and the `--json` `Finding` shape. No `main`, no `exports` —
|
|
134
|
+
it is a binary, not a library. The version answers one question: *can taking this
|
|
135
|
+
upgrade turn a green build red without me changing anything?* **Patch never can** —
|
|
136
|
+
bypass fixes and false-positive fixes ship as patches so they reach you automatically.
|
|
137
|
+
Rule graduations (`warn` → `block`) are **opt-in**: they gate on the `version:` field in
|
|
138
|
+
your policy, so they ship as minors and apply only when you raise it. Releases publish
|
|
139
|
+
via npm trusted publishing with SLSA provenance. Full rule:
|
|
140
|
+
[CONTRIBUTING](./CONTRIBUTING.md#versioning).
|
|
152
141
|
|
|
153
142
|
## Develop
|
|
154
143
|
|
|
155
144
|
```bash
|
|
156
|
-
npm
|
|
145
|
+
npm install && npm run build # bundles the CLI to dist/cli/index.js
|
|
146
|
+
npm test # 259 tests — parser, detectors, engine, policy, renderers
|
|
157
147
|
npm run typecheck
|
|
158
148
|
```
|
|
159
149
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
src/diff/ pure diff parser + selectors
|
|
166
|
-
src/git/ git adapter (range / staged / worktree)
|
|
167
|
-
src/detectors/ the rules (phase 1, in progress)
|
|
168
|
-
test/ unit suite — green is the gate
|
|
169
|
-
.tamperward.yml the policy: protected assets + rule severities
|
|
170
|
-
.github/workflows/ CI — dogfoods Tamperward on itself once the CLI lands
|
|
171
|
-
```
|
|
150
|
+
Tamperward gates its own repo in CI with the same engine it ships — including, on more
|
|
151
|
+
than one occasion, blocking its own author's commits. See **[SPEC.md](./SPEC.md)** for
|
|
152
|
+
the build spec, the detector table, the enforcement-point wiring, and the proof-harness
|
|
153
|
+
design; the `harness/` directory holds the seeds, oracles, transcripts tooling, and
|
|
154
|
+
every pre-registered prediction with its outcome.
|
package/dist/cli/index.js
CHANGED
|
@@ -2049,6 +2049,197 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2049
2049
|
return 0;
|
|
2050
2050
|
}
|
|
2051
2051
|
|
|
2052
|
+
// src/cli/init.ts
|
|
2053
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3, chmodSync } from "node:fs";
|
|
2054
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
2055
|
+
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2056
|
+
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2057
|
+
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
2058
|
+
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2059
|
+
var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
|
|
2060
|
+
# without this file \u2014 everything here is an override, so an empty file changes nothing.
|
|
2061
|
+
# Docs: https://github.com/hexrift/tamperward#readme
|
|
2062
|
+
#
|
|
2063
|
+
# version gates rule GRADUATIONS: a baseline rule promoted warn -> block at policy
|
|
2064
|
+
# version N blocks only when you declare version >= N. Raising it is opting in.
|
|
2065
|
+
version: 1
|
|
2066
|
+
|
|
2067
|
+
# protected: # categories MERGE with the baseline (additive, never replace)
|
|
2068
|
+
# tests: ['e2e/**']
|
|
2069
|
+
# rules: # an explicit severity wins over the baseline in either direction
|
|
2070
|
+
# snapshot-rewrite: { severity: block }
|
|
2071
|
+
# ignore: [] # visible blind spots \u2014 the count is always reported
|
|
2072
|
+
`;
|
|
2073
|
+
var WORKFLOW_CONTENT = `name: tamperward
|
|
2074
|
+
|
|
2075
|
+
# The CI authority for main: the same engine as the agent hook and pre-commit, run over
|
|
2076
|
+
# the PR's commit range. A block fails the check and clears ONLY via the out-of-band
|
|
2077
|
+
# label \`tamperward:allow:<rule>\` applied by someone with write access \u2014 never a file
|
|
2078
|
+
# the PR itself can commit.
|
|
2079
|
+
#
|
|
2080
|
+
# labeled/unlabeled re-run the gate because the sign-off is read from the EVENT payload:
|
|
2081
|
+
# a label applied after a failure could otherwise never take effect, and REVOKING a
|
|
2082
|
+
# sign-off must re-block rather than linger green.
|
|
2083
|
+
on:
|
|
2084
|
+
pull_request:
|
|
2085
|
+
types: [opened, synchronize, reopened, labeled, unlabeled]
|
|
2086
|
+
|
|
2087
|
+
permissions:
|
|
2088
|
+
contents: read
|
|
2089
|
+
|
|
2090
|
+
jobs:
|
|
2091
|
+
tamperward:
|
|
2092
|
+
runs-on: ubuntu-latest
|
|
2093
|
+
timeout-minutes: 10
|
|
2094
|
+
steps:
|
|
2095
|
+
- uses: actions/checkout@v5
|
|
2096
|
+
with:
|
|
2097
|
+
fetch-depth: 0 # the range diff needs both endpoints
|
|
2098
|
+
- uses: actions/setup-node@v6
|
|
2099
|
+
with:
|
|
2100
|
+
node-version: 22
|
|
2101
|
+
- name: Resolve out-of-band sign-off from PR labels
|
|
2102
|
+
id: oob
|
|
2103
|
+
env:
|
|
2104
|
+
LABELS: \${{ toJSON(github.event.pull_request.labels.*.name) }}
|
|
2105
|
+
run: |
|
|
2106
|
+
RULES="$(printf '%s' "$LABELS" | jq -r '.[] | select(startswith("tamperward:allow:")) | sub("^tamperward:allow:"; "")' | paste -sd, -)"
|
|
2107
|
+
echo "rules=$RULES" >> "$GITHUB_OUTPUT"
|
|
2108
|
+
- name: Tamperward gate
|
|
2109
|
+
env:
|
|
2110
|
+
TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
|
|
2111
|
+
run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
|
|
2112
|
+
`;
|
|
2113
|
+
function planPolicy(cwd) {
|
|
2114
|
+
const path = join6(cwd, POLICY_FILE);
|
|
2115
|
+
if (existsSync5(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
|
|
2116
|
+
return {
|
|
2117
|
+
item: "policy",
|
|
2118
|
+
path: POLICY_FILE,
|
|
2119
|
+
status: "create",
|
|
2120
|
+
detail: "baseline policy with commented overrides",
|
|
2121
|
+
apply: () => writeFileSync3(path, POLICY_CONTENT)
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
function planClaudeHooks(cwd) {
|
|
2125
|
+
const rel = ".claude/settings.json";
|
|
2126
|
+
const path = join6(cwd, rel);
|
|
2127
|
+
let settings = {};
|
|
2128
|
+
if (existsSync5(path)) {
|
|
2129
|
+
let parsed;
|
|
2130
|
+
try {
|
|
2131
|
+
parsed = JSON.parse(readFileSync7(path, "utf8"));
|
|
2132
|
+
} catch {
|
|
2133
|
+
return {
|
|
2134
|
+
item: "agent",
|
|
2135
|
+
path: rel,
|
|
2136
|
+
status: "error",
|
|
2137
|
+
detail: "exists but is not valid JSON \u2014 fix it, then re-run init (refusing to overwrite)"
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2141
|
+
return { item: "agent", path: rel, status: "error", detail: "exists but is not a JSON object \u2014 refusing to overwrite" };
|
|
2142
|
+
}
|
|
2143
|
+
settings = parsed;
|
|
2144
|
+
}
|
|
2145
|
+
const hooks = settings.hooks ??= {};
|
|
2146
|
+
const has = (arr, needle) => (arr ?? []).some((m) => (m.hooks ?? []).some((h) => String(h.command ?? "").includes(needle)));
|
|
2147
|
+
const needPre = !has(hooks.PreToolUse, "tamperward hook claude");
|
|
2148
|
+
const needStop = !has(hooks.Stop, "tamperward sweep claude");
|
|
2149
|
+
if (!needPre && !needStop) return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
2150
|
+
return {
|
|
2151
|
+
item: "agent",
|
|
2152
|
+
path: rel,
|
|
2153
|
+
status: existsSync5(path) ? "update" : "create",
|
|
2154
|
+
detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
|
|
2155
|
+
apply: () => {
|
|
2156
|
+
if (needPre) {
|
|
2157
|
+
(hooks.PreToolUse ??= []).push({
|
|
2158
|
+
matcher: "Bash|Edit|Write|MultiEdit",
|
|
2159
|
+
hooks: [{ type: "command", command: HOOK_CMD }]
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
if (needStop) {
|
|
2163
|
+
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
2164
|
+
}
|
|
2165
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
2166
|
+
writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n");
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
function planPreCommit(cwd) {
|
|
2171
|
+
const line = `${MARKER}
|
|
2172
|
+
${PRECOMMIT_CMD}
|
|
2173
|
+
`;
|
|
2174
|
+
const husky = join6(cwd, ".husky");
|
|
2175
|
+
const gitDir2 = join6(cwd, ".git");
|
|
2176
|
+
const target = existsSync5(husky) ? { rel: ".husky/pre-commit", note: "husky" } : existsSync5(gitDir2) ? { rel: ".git/hooks/pre-commit", note: "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" } : null;
|
|
2177
|
+
if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
|
|
2178
|
+
const path = join6(cwd, target.rel);
|
|
2179
|
+
const existing = existsSync5(path) ? readFileSync7(path, "utf8") : null;
|
|
2180
|
+
if (existing?.includes("tamperward check --staged")) {
|
|
2181
|
+
return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
|
|
2182
|
+
}
|
|
2183
|
+
return {
|
|
2184
|
+
item: "pre-commit",
|
|
2185
|
+
path: target.rel,
|
|
2186
|
+
status: existing === null ? "create" : "update",
|
|
2187
|
+
detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
|
|
2188
|
+
apply: () => {
|
|
2189
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
2190
|
+
const content = existing === null ? `#!/bin/sh
|
|
2191
|
+
${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
2192
|
+
writeFileSync3(path, content);
|
|
2193
|
+
chmodSync(path, 493);
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
function planWorkflow(cwd) {
|
|
2198
|
+
const rel = ".github/workflows/tamperward.yml";
|
|
2199
|
+
const path = join6(cwd, rel);
|
|
2200
|
+
if (existsSync5(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
|
|
2201
|
+
return {
|
|
2202
|
+
item: "ci",
|
|
2203
|
+
path: rel,
|
|
2204
|
+
status: "create",
|
|
2205
|
+
detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
|
|
2206
|
+
apply: () => {
|
|
2207
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
2208
|
+
writeFileSync3(path, WORKFLOW_CONTENT);
|
|
2209
|
+
}
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
function planInit(cwd) {
|
|
2213
|
+
return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd)];
|
|
2214
|
+
}
|
|
2215
|
+
function runInit(opts) {
|
|
2216
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2217
|
+
const plan = planInit(cwd);
|
|
2218
|
+
const w = process.stdout;
|
|
2219
|
+
for (const a of plan) {
|
|
2220
|
+
const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
|
|
2221
|
+
w.write(` ${a.item.padEnd(10)} ${verb.padEnd(12)} ${a.path} \u2014 ${a.detail}
|
|
2222
|
+
`);
|
|
2223
|
+
if (!opts.dryRun && a.apply) a.apply();
|
|
2224
|
+
}
|
|
2225
|
+
const errors = plan.filter((a) => a.status === "error");
|
|
2226
|
+
const changed = plan.filter((a) => a.apply).length;
|
|
2227
|
+
if (errors.length) {
|
|
2228
|
+
w.write(`
|
|
2229
|
+
tamperward init: ${errors.length} item(s) need your attention above; the rest ${opts.dryRun ? "are planned" : "were applied"}.
|
|
2230
|
+
`);
|
|
2231
|
+
return 2;
|
|
2232
|
+
}
|
|
2233
|
+
w.write(
|
|
2234
|
+
changed === 0 ? "\ntamperward init: everything already wired \u2014 nothing to do.\n" : opts.dryRun ? `
|
|
2235
|
+
tamperward init: ${changed} change(s) planned. Re-run without --dry-run to apply.
|
|
2236
|
+
` : `
|
|
2237
|
+
tamperward init: ${changed} change(s) applied. Commit them so the gate travels with the repo.
|
|
2238
|
+
`
|
|
2239
|
+
);
|
|
2240
|
+
return 0;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2052
2243
|
// src/cli/index.ts
|
|
2053
2244
|
function parseAllow(args) {
|
|
2054
2245
|
const o = {};
|
|
@@ -2070,6 +2261,15 @@ function runAgentCommand(kind, args) {
|
|
|
2070
2261
|
}
|
|
2071
2262
|
return kind === "hook" ? runHookClaude() : runSweepClaude();
|
|
2072
2263
|
}
|
|
2264
|
+
function parseInit(args) {
|
|
2265
|
+
const o = {};
|
|
2266
|
+
for (let i = 0; i < args.length; i++) {
|
|
2267
|
+
const a = args[i];
|
|
2268
|
+
if (a === "--cwd") o.cwd = args[++i];
|
|
2269
|
+
else if (a === "--dry-run") o.dryRun = true;
|
|
2270
|
+
}
|
|
2271
|
+
return o;
|
|
2272
|
+
}
|
|
2073
2273
|
function parseCheck(args) {
|
|
2074
2274
|
const o = {};
|
|
2075
2275
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -2113,6 +2313,9 @@ Formats:
|
|
|
2113
2313
|
tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
|
|
2114
2314
|
tamperward sweep claude Stop sweep (re-scan the turn's working tree)
|
|
2115
2315
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
2316
|
+
tamperward init [--dry-run] wire all four enforcement points: policy
|
|
2317
|
+
file, Claude Code hooks, pre-commit, CI.
|
|
2318
|
+
Idempotent; never overwrites your files.
|
|
2116
2319
|
|
|
2117
2320
|
Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
|
|
2118
2321
|
emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
|
|
@@ -2129,6 +2332,8 @@ function main(argv) {
|
|
|
2129
2332
|
return runAgentCommand("sweep", rest);
|
|
2130
2333
|
case "allow":
|
|
2131
2334
|
return runAllow(parseAllow(rest));
|
|
2335
|
+
case "init":
|
|
2336
|
+
return runInit(parseInit(rest));
|
|
2132
2337
|
case void 0:
|
|
2133
2338
|
case "-h":
|
|
2134
2339
|
case "--help":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "hexrift",
|
|
@@ -14,15 +14,18 @@
|
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"ai-agents",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"coding-agents",
|
|
17
19
|
"agent-safety",
|
|
20
|
+
"reward-hacking",
|
|
18
21
|
"code-integrity",
|
|
19
22
|
"guardrails",
|
|
20
|
-
"
|
|
23
|
+
"test-integrity",
|
|
21
24
|
"pre-commit",
|
|
22
25
|
"ci",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
+
"hooks",
|
|
27
|
+
"llm",
|
|
28
|
+
"devsecops"
|
|
26
29
|
],
|
|
27
30
|
"type": "module",
|
|
28
31
|
"bin": {
|