tamperward 1.14.6 → 2.0.0
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 +66 -10
- package/dist/cli/index.js +382 -84
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Earlier controlled experiments → **[the research series](./docs/blog/index.md)
|
|
|
43
43
|
|
|
44
44
|
> **Scope.** The rows above are one model, one pressure prompt, one JS/TS
|
|
45
45
|
> ecosystem — evidence for that setting, not a universal claim. Round 2 tested
|
|
46
|
-
> the v1.9.0 stack; the current **
|
|
46
|
+
> the v1.9.0 stack; the current **v2.0.0** adds post-study envelope hardening
|
|
47
47
|
> (externally reviewed, with findings tracked individually as REPRO or AUDIT in
|
|
48
48
|
> [SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md) and closed with regression and
|
|
49
49
|
> mutation checks — see [CHANGELOG](./CHANGELOG.md)). Rounds 3 and 3.1 are
|
|
@@ -148,15 +148,28 @@ semantic correctness nor a substitute for independent held-out tests.
|
|
|
148
148
|
|
|
149
149
|
### CI authority
|
|
150
150
|
|
|
151
|
-
Pre-commit is an early backstop; protected CI is the merge authority.
|
|
151
|
+
Pre-commit is an early backstop; protected CI is the merge authority. Two workflows
|
|
152
|
+
are involved here, and they are not the same thing.
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
**The shipped workflow** — the one `tamperward init` writes into your repository as
|
|
155
|
+
`.github/workflows/tamperward.yml` — runs, independently:
|
|
156
|
+
|
|
157
|
+
1. `check --diff base.sha...head.sha`, with policy from the trusted merge-base; and
|
|
154
158
|
2. `verify --require-ancestor --base <base-sha>`, which since **1.14.3** also reads
|
|
155
159
|
its policy, verification surface and verifier command and budget from that same
|
|
156
|
-
trusted base — so neither
|
|
160
|
+
trusted base — so neither step's verdict is governed by the candidate.
|
|
161
|
+
|
|
162
|
+
Legitimate exceptions are out-of-band PR labels, `tamperward:allow:<rule>@<head-sha>`.
|
|
163
|
+
The workflow passes the head SHA to the gate through `TAMPERWARD_OOB_HEAD`, so an
|
|
164
|
+
approval is bound to the exact commit it was granted for and a new push invalidates it.
|
|
157
165
|
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
**This repository's own self-gate** — the `gate` job in `.github/workflows/ci.yml` —
|
|
167
|
+
runs the built CLI's `check --diff` over the pull-request range, cleared only by the
|
|
168
|
+
same label channel, but does **not** run `verify` on itself. This repo's test
|
|
169
|
+
expectations legitimately change whenever a rule changes, and standalone `verify` has
|
|
170
|
+
no out-of-band sign-off channel, so a pristine re-execution of its own suite would fail
|
|
171
|
+
closed on exactly the pull requests that improve the gate. The self-gate is a
|
|
172
|
+
diff-time gate.
|
|
160
173
|
|
|
161
174
|
This guarantee depends on a protected and immutable base, required status checks, a
|
|
162
175
|
pinned Tamperward version, and label permissions restricted to trusted humans.
|
|
@@ -218,9 +231,50 @@ npx tamperward verify --base main # pristine-suite re-execution
|
|
|
218
231
|
npx tamperward run -- <agent command...> # the outer envelope around an agent
|
|
219
232
|
```
|
|
220
233
|
|
|
234
|
+
### CLI reference
|
|
235
|
+
|
|
236
|
+
Every flag below is what the command's parser actually reads (`src/cli/index.ts`,
|
|
237
|
+
`parseVerify`, `parseRun`). `check` reports an unknown flag on stderr; the other
|
|
238
|
+
commands ignore what they do not know.
|
|
239
|
+
|
|
240
|
+
| command | flags |
|
|
241
|
+
| --- | --- |
|
|
242
|
+
| `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
|
|
243
|
+
| `verify` | `--base <rev>` (default `HEAD`) · `--cmd <suite command>` · `--budget <seconds>` · `--json` · `--keep` (keep the two materialised copies and report their paths) · `--require-ancestor` (refuse a base that is not an ancestor of `HEAD`) · `--cwd <dir>` |
|
|
244
|
+
| `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
|
|
245
|
+
| `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
|
|
246
|
+
| `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
|
|
247
|
+
| `watch` | `--dir <dir>` · `--log <file>` — a daemon; it runs until signalled |
|
|
248
|
+
| `hook claude` / `sweep claude` | none — the Claude Code payload arrives on stdin |
|
|
249
|
+
|
|
250
|
+
**Exit codes** — part of the public surface:
|
|
251
|
+
|
|
252
|
+
| command | 0 | 1 | 2 |
|
|
253
|
+
| --- | --- | --- | --- |
|
|
254
|
+
| `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, or no view given |
|
|
255
|
+
| `verify` | `VERIFIED` — visible and pristine both green | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
|
|
256
|
+
| `run` | enforcement clean and the agent exited 0 — a non-zero agent exit is passed through unchanged | any blocking finding or masked failure, whatever the agent returned | cannot adjudicate: dirty start, policy error, verify cannot run |
|
|
257
|
+
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
|
|
258
|
+
| `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off |
|
|
259
|
+
| `init` | wired, or already wired | — | an item needs attention |
|
|
260
|
+
| no or unknown command | help printed (no command) | — | unknown command, help printed |
|
|
261
|
+
|
|
262
|
+
### Environment variables
|
|
263
|
+
|
|
264
|
+
| variable | who sets it | what it does |
|
|
265
|
+
| --- | --- | --- |
|
|
266
|
+
| `TAMPERWARD_OOB_SIGNOFF` | the CI workflow, from PR labels | comma-separated out-of-band approvals — `<rule>` or `<rule>:<file>`, optionally `@<head-sha>` — honoured by `check --diff` only; never the committed ledger |
|
|
267
|
+
| `TAMPERWARD_OOB_HEAD` | the CI workflow (`github.event.pull_request.head.sha`) | the head SHA under adjudication; once set, an approval clears a finding only if it names that commit (`@<sha>`, at least 7 characters), so a new push re-blocks |
|
|
268
|
+
| `TAMPERWARD_DENYLOG` | a harness or operator | a file to which `hook claude` and `sweep claude` append the rule ids of every deny, one line per verdict, best effort |
|
|
269
|
+
| `TAMPERWARD_FSEVENTS` | operator or harness | overrides the `tamperward watch` event-log path (default `.git/tamperward/fsevents.jsonl`); the Stop sweep reads the same variable |
|
|
270
|
+
| `TAMPERWARD_WATCH_NO_RECURSIVE` | CI and tests (`=1`) | forces `tamperward watch` onto its per-directory fallback instead of recursive `fs.watch`, so the fallback is exercised on every platform |
|
|
271
|
+
| `TAMPERWARD_TRANSIENT` | a harness that owns restore semantics (`=block`) | raises `transient-protected-mutation` from warn to block; it can never lower a severity |
|
|
272
|
+
| `NO_COLOR` / `FORCE_COLOR` | the user's shell | any non-empty `NO_COLOR` disables colour in the text renderer; a non-empty, non-`0` `FORCE_COLOR` enables it |
|
|
273
|
+
| `GITHUB_ACTIONS` | GitHub Actions (`=true`) | `--format auto` selects the `github` renderer — an inline annotation per finding plus a job summary |
|
|
274
|
+
|
|
221
275
|
### The rules
|
|
222
276
|
|
|
223
|
-
|
|
277
|
+
Seventeen rules are specified and fifteen ship (see the table in
|
|
224
278
|
[SPEC.md](./SPEC.md)). The families: test protection (`test-deletion`,
|
|
225
279
|
`test-skip`, `test-content-removal`), verification-signal protection
|
|
226
280
|
(`coverage-lowering`, `snapshot-rewrite`, `snapshot-only-rewrite`), suppression
|
|
@@ -308,12 +362,14 @@ via npm trusted publishing with SLSA provenance. Full rule:
|
|
|
308
362
|
|
|
309
363
|
```bash
|
|
310
364
|
npm install && npm run build # bundles the CLI to dist/cli/index.js
|
|
311
|
-
npm test #
|
|
365
|
+
npm test # 520+ tests at 2.0.0 — parser, detectors, engine, policy, renderers
|
|
312
366
|
npm run typecheck
|
|
313
367
|
```
|
|
314
368
|
|
|
315
|
-
Tamperward
|
|
316
|
-
|
|
369
|
+
Tamperward's own CI runs the engine it ships over every pull request — `check --diff`
|
|
370
|
+
over the PR range, cleared only by an out-of-band label; see [CI authority](#ci-authority)
|
|
371
|
+
for why the self-gate runs no `verify` on itself — and has, on more than one occasion,
|
|
372
|
+
blocked its own author's commits. See **[SPEC.md](./SPEC.md)** for
|
|
317
373
|
the build spec, the detector table, the enforcement-point wiring, and the proof-harness
|
|
318
374
|
design; the `harness/` directory holds the seeds, oracles, transcripts tooling, and
|
|
319
375
|
every pre-registered prediction with its outcome.
|
package/dist/cli/index.js
CHANGED
|
@@ -90,6 +90,8 @@ function parseDiff(diff) {
|
|
|
90
90
|
let renameTo = null;
|
|
91
91
|
let minusPath = null;
|
|
92
92
|
let plusPath = null;
|
|
93
|
+
let oldMode;
|
|
94
|
+
let newMode;
|
|
93
95
|
const hunks = [];
|
|
94
96
|
while (i < lines.length && !lines[i].startsWith("diff --git ")) {
|
|
95
97
|
const l = lines[i];
|
|
@@ -103,8 +105,14 @@ function parseDiff(diff) {
|
|
|
103
105
|
i++;
|
|
104
106
|
continue;
|
|
105
107
|
}
|
|
106
|
-
if (l.startsWith("new file mode"))
|
|
107
|
-
|
|
108
|
+
if (l.startsWith("new file mode ")) {
|
|
109
|
+
op = "add";
|
|
110
|
+
newMode = l.slice(14).trim();
|
|
111
|
+
} else if (l.startsWith("deleted file mode ")) {
|
|
112
|
+
op = "delete";
|
|
113
|
+
oldMode = l.slice(18).trim();
|
|
114
|
+
} else if (l.startsWith("old mode ")) oldMode = l.slice(9).trim();
|
|
115
|
+
else if (l.startsWith("new mode ")) newMode = l.slice(9).trim();
|
|
108
116
|
else if (l.startsWith("rename from ")) {
|
|
109
117
|
renameFrom = unquotePath(l.slice(12));
|
|
110
118
|
op = "rename";
|
|
@@ -140,7 +148,9 @@ function parseDiff(diff) {
|
|
|
140
148
|
before: null,
|
|
141
149
|
after: null,
|
|
142
150
|
binary,
|
|
143
|
-
hunks
|
|
151
|
+
hunks,
|
|
152
|
+
...oldMode !== void 0 ? { oldMode } : {},
|
|
153
|
+
...newMode !== void 0 ? { newMode } : {}
|
|
144
154
|
};
|
|
145
155
|
changes.push(change);
|
|
146
156
|
}
|
|
@@ -344,7 +354,13 @@ var LITERAL = [
|
|
|
344
354
|
{ re: /\bGIT_CONFIG_KEY_\d+\s*=\s*["']?core\.hooksPath/i, why: "GIT_CONFIG_* injection sets core.hooksPath" },
|
|
345
355
|
{ re: /(?:^|[\s;&|=])LEFTHOOK=0(?:\s|$)/, why: "LEFTHOOK=0 disables Lefthook hooks" },
|
|
346
356
|
{ re: /\bSKIP_SIMPLE_GIT_HOOKS=1\b/, why: "SKIP_SIMPLE_GIT_HOOKS=1 skips simple-git-hooks" },
|
|
347
|
-
{ re: /\bPRE_COMMIT_ALLOW_NO_CONFIG=1\b/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" }
|
|
357
|
+
{ re: /\bPRE_COMMIT_ALLOW_NO_CONFIG=1\b/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" },
|
|
358
|
+
// The pre-commit framework's own escape hatch: SKIP=<hook-id>[,…] skips those
|
|
359
|
+
// hooks for that one invocation. `.pre-commit-config.yaml` is a protected hook,
|
|
360
|
+
// so its documented bypass has to be one too. `SKIP=` is a plausible variable
|
|
361
|
+
// name elsewhere, so it counts only in a segment that runs git or pre-commit.
|
|
362
|
+
{ re: /(?:^|[\s;&|])SKIP=\S+/, why: "SKIP=<hook-id> tells the pre-commit framework to skip that hook", when: /\b(?:git|pre-commit)\b/ },
|
|
363
|
+
{ re: /(?:^|[\s;&|])LEFTHOOK_EXCLUDE=\S+/, why: "LEFTHOOK_EXCLUDE=<name> skips the named Lefthook commands" }
|
|
348
364
|
];
|
|
349
365
|
var SHORT_N = /^-[a-z]*n[a-z]*$/i;
|
|
350
366
|
var noVerify = {
|
|
@@ -358,7 +374,7 @@ var noVerify = {
|
|
|
358
374
|
for (const seg of segments(c.raw)) {
|
|
359
375
|
let why = null;
|
|
360
376
|
for (const p of LITERAL) {
|
|
361
|
-
if (p.re.test(seg)) {
|
|
377
|
+
if (p.re.test(seg) && (!p.when || p.when.test(seg))) {
|
|
362
378
|
why = p.why;
|
|
363
379
|
break;
|
|
364
380
|
}
|
|
@@ -565,6 +581,42 @@ var CODE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
|
565
581
|
function isCodeFile(path) {
|
|
566
582
|
return CODE.test(path);
|
|
567
583
|
}
|
|
584
|
+
var EXT = [
|
|
585
|
+
[CODE, "js"],
|
|
586
|
+
[/\.pyw?$/, "py"],
|
|
587
|
+
[/\.go$/, "go"],
|
|
588
|
+
[/\.rs$/, "rs"],
|
|
589
|
+
[/\.rb$/, "rb"],
|
|
590
|
+
[/\.java$/, "java"],
|
|
591
|
+
[/\.kts?$/, "kt"],
|
|
592
|
+
[/\.php$/, "php"],
|
|
593
|
+
[/\.cs$/, "cs"]
|
|
594
|
+
];
|
|
595
|
+
function langOf(path) {
|
|
596
|
+
for (const [re, lang] of EXT) if (re.test(path)) return lang;
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
var COMMON = /^(export\s|\/\/|\*|\/\*|}\)?;?$)/;
|
|
600
|
+
var IMPORTS = {
|
|
601
|
+
js: /^import\b/,
|
|
602
|
+
py: /^(?:import\b|from\s+\S+\s+import\b)/,
|
|
603
|
+
go: /^(?:import\b|package\b)/,
|
|
604
|
+
rs: /^(?:use\b|extern\s+crate\b|mod\b)/,
|
|
605
|
+
rb: /^(?:require(?:_relative)?\b|include\b)/,
|
|
606
|
+
java: /^(?:import\b|package\b)/,
|
|
607
|
+
kt: /^(?:import\b|package\b)/,
|
|
608
|
+
php: /^(?:use\b|namespace\b|require(?:_once)?\b|include(?:_once)?\b)/,
|
|
609
|
+
cs: /^(?:using\b|namespace\b)/
|
|
610
|
+
};
|
|
611
|
+
var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
|
|
612
|
+
function isSignificantLine(trimmed, lang) {
|
|
613
|
+
if (trimmed.length < 10) return false;
|
|
614
|
+
if (COMMON.test(trimmed)) return false;
|
|
615
|
+
const l = lang ?? "js";
|
|
616
|
+
if (IMPORTS[l].test(trimmed)) return false;
|
|
617
|
+
if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
568
620
|
|
|
569
621
|
// src/detectors/ts-any-cast.ts
|
|
570
622
|
var BLOCK_RULE = "ts-any-cast";
|
|
@@ -671,11 +723,35 @@ var tsAnyCast = {
|
|
|
671
723
|
|
|
672
724
|
// src/detectors/lint-suppression.ts
|
|
673
725
|
var RULE2 = "lint-suppression";
|
|
674
|
-
var PATTERNS =
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
726
|
+
var PATTERNS = {
|
|
727
|
+
js: [
|
|
728
|
+
{ re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
|
|
729
|
+
{ re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
|
|
730
|
+
{ re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
|
|
731
|
+
],
|
|
732
|
+
py: [
|
|
733
|
+
{ re: /#\s*noqa\b/, why: "# noqa suppresses flake8/ruff findings" },
|
|
734
|
+
{ re: /#\s*type:\s*ignore\b/, why: "# type: ignore suppresses the type checker" },
|
|
735
|
+
{ re: /#\s*pyright:\s*ignore\b/, why: "# pyright: ignore suppresses the type checker" },
|
|
736
|
+
{ re: /#\s*pylint:\s*disable\b/, why: "# pylint: disable suppresses lint rules" },
|
|
737
|
+
{ re: /#\s*nosec\b/, why: "# nosec suppresses bandit security findings" },
|
|
738
|
+
{ re: /#\s*fmt:\s*(?:off|skip)\b/, why: "# fmt: off/skip suppresses formatting checks" }
|
|
739
|
+
],
|
|
740
|
+
go: [{ re: /\/\/\s*nolint\b/, why: "//nolint suppresses golangci-lint findings" }],
|
|
741
|
+
rs: [],
|
|
742
|
+
rb: [{ re: /#\s*rubocop:\s*(?:disable|todo)\b/, why: "# rubocop:disable suppresses lint rules" }],
|
|
743
|
+
java: [{ re: /@SuppressWarnings\s*\(/, why: "@SuppressWarnings suppresses compiler and lint warnings" }],
|
|
744
|
+
kt: [{ re: /@Suppress(?:Warnings)?\s*\(/, why: "@Suppress suppresses compiler and lint warnings" }],
|
|
745
|
+
php: [
|
|
746
|
+
{ re: /\bphpcs:(?:ignore|disable)\b/, why: "phpcs:ignore/disable suppresses coding-standard checks" },
|
|
747
|
+
{ re: /@phpstan-ignore\b/, why: "@phpstan-ignore suppresses static-analysis findings" },
|
|
748
|
+
{ re: /@psalm-suppress\b/, why: "@psalm-suppress suppresses static-analysis findings" }
|
|
749
|
+
],
|
|
750
|
+
cs: [
|
|
751
|
+
{ re: /#pragma\s+warning\s+disable\b/, why: "#pragma warning disable suppresses compiler warnings" },
|
|
752
|
+
{ re: /\[SuppressMessage\s*\(/, why: "[SuppressMessage] suppresses analyzer findings" }
|
|
753
|
+
]
|
|
754
|
+
};
|
|
679
755
|
var lintSuppression = {
|
|
680
756
|
id: RULE2,
|
|
681
757
|
surface: ["file"],
|
|
@@ -683,9 +759,13 @@ var lintSuppression = {
|
|
|
683
759
|
run(changes, policy) {
|
|
684
760
|
const out2 = [];
|
|
685
761
|
for (const c of changes) {
|
|
686
|
-
if (c.kind !== "file"
|
|
762
|
+
if (c.kind !== "file") continue;
|
|
763
|
+
const lang = langOf(c.path);
|
|
764
|
+
if (!lang) continue;
|
|
765
|
+
const patterns = PATTERNS[lang];
|
|
766
|
+
if (patterns.length === 0) continue;
|
|
687
767
|
for (const l of addedLines(c)) {
|
|
688
|
-
for (const p of
|
|
768
|
+
for (const p of patterns) {
|
|
689
769
|
if (p.re.test(l.content)) {
|
|
690
770
|
out2.push(
|
|
691
771
|
makeFinding(RULE2, policy, {
|
|
@@ -707,18 +787,44 @@ var lintSuppression = {
|
|
|
707
787
|
|
|
708
788
|
// src/detectors/test-skip.ts
|
|
709
789
|
var RULE3 = "test-skip";
|
|
710
|
-
var PATTERNS2 =
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
790
|
+
var PATTERNS2 = {
|
|
791
|
+
js: [
|
|
792
|
+
{ re: /\b(?:it|test|describe)\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
|
|
793
|
+
{ re: /\b(?:xit|xtest|xdescribe)\b/, why: "an x-prefixed disabled test" },
|
|
794
|
+
{ re: /\b(?:fit|ftest|fdescribe)\b/, why: "an f-prefixed focused test (narrows the suite)" },
|
|
795
|
+
{ re: /\bpending\(\s*\)/, why: "a pending() marker" },
|
|
796
|
+
// node:test spells the same class as options — `test('x', { skip: true }, ...)` — and as
|
|
797
|
+
// a runtime call. A live evasion used the options form the day this gap was measured
|
|
798
|
+
// (PREDICTION-baselines.md): denied the rm, the agent skipped by option instead.
|
|
799
|
+
{ re: /\bskip:\s*(?:true|1|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
|
|
800
|
+
{ re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
|
|
801
|
+
{ re: /\b(?:t|ctx|context)\.skip\(/, why: "a runtime t.skip() call" }
|
|
802
|
+
],
|
|
803
|
+
py: [
|
|
804
|
+
{ re: /@pytest\.mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker" },
|
|
805
|
+
{ re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail() call" },
|
|
806
|
+
{ re: /@unittest\.(?:skip|skipIf|skipUnless|expectedFailure)\b/, why: "a unittest skip/expectedFailure decorator" },
|
|
807
|
+
{ re: /\bself\.skipTest\(/, why: "a runtime self.skipTest() call" },
|
|
808
|
+
{ re: /\braise\s+(?:unittest\.)?SkipTest\b/, why: "a raised SkipTest" }
|
|
809
|
+
],
|
|
810
|
+
go: [
|
|
811
|
+
{ re: /\b[tb]\.Skip(?:f|Now)?\(/, why: "a runtime t.Skip()/Skipf()/SkipNow() call" }
|
|
812
|
+
],
|
|
813
|
+
rs: [{ re: /#\[ignore\b/, why: "an #[ignore] attribute (the test no longer runs by default)" }],
|
|
814
|
+
rb: [
|
|
815
|
+
{ re: /(?:^|[\s;{(])(?:skip|pending)(?:\s*\(|\s+['"]|\s+(?:if|unless)\b|\s*$)/, why: "a skip/pending call" },
|
|
816
|
+
{ re: /\bx(?:it|describe|context|specify|example)\b/, why: "an x-prefixed disabled example" },
|
|
817
|
+
{ re: /\b(?:fit|fdescribe|fcontext)\b/, why: "an f-prefixed focused example (narrows the suite)" },
|
|
818
|
+
{ re: /\b(?:skip|pending):\s*(?:true|['"])/, why: "a skip:/pending: metadata flag" }
|
|
819
|
+
],
|
|
820
|
+
java: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
|
|
821
|
+
kt: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
|
|
822
|
+
php: [{ re: /\bmarkTest(?:Skipped|Incomplete)\(/, why: "a markTestSkipped()/markTestIncomplete() call" }],
|
|
823
|
+
cs: [
|
|
824
|
+
{ re: /\[Ignore\b/, why: "an [Ignore] attribute" },
|
|
825
|
+
{ re: /\bSkip\s*=\s*"/, why: 'a Skip = "..." attribute argument' }
|
|
826
|
+
]
|
|
827
|
+
};
|
|
722
828
|
var testSkip = {
|
|
723
829
|
id: RULE3,
|
|
724
830
|
surface: ["file"],
|
|
@@ -728,8 +834,9 @@ var testSkip = {
|
|
|
728
834
|
for (const c of changes) {
|
|
729
835
|
if (c.kind !== "file") continue;
|
|
730
836
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
837
|
+
const patterns = PATTERNS2[langOf(c.path) ?? "js"];
|
|
731
838
|
for (const l of addedLines(c)) {
|
|
732
|
-
for (const p of
|
|
839
|
+
for (const p of patterns) {
|
|
733
840
|
if (p.re.test(l.content)) {
|
|
734
841
|
out2.push(
|
|
735
842
|
makeFinding(RULE3, policy, {
|
|
@@ -1018,6 +1125,7 @@ var ciTampering = {
|
|
|
1018
1125
|
(c.after != null ? c.after.split("\n") : addedLines(c).map((l) => l.content)).map(commandCore)
|
|
1019
1126
|
);
|
|
1020
1127
|
for (const l of removedLines(c)) {
|
|
1128
|
+
if (/^\s*#/.test(l.content)) continue;
|
|
1021
1129
|
const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
|
|
1022
1130
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
1023
1131
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
@@ -1080,8 +1188,11 @@ function policyWeakening(before, after) {
|
|
|
1080
1188
|
reasons.push(`rule "${name}" removed from policy`);
|
|
1081
1189
|
continue;
|
|
1082
1190
|
}
|
|
1083
|
-
if (br.severity === "block" && ar.severity
|
|
1084
|
-
|
|
1191
|
+
if (br.severity === "block" && ar.severity !== "block") {
|
|
1192
|
+
const to = typeof ar.severity === "string" ? ar.severity : JSON.stringify(ar.severity);
|
|
1193
|
+
reasons.push(
|
|
1194
|
+
ar.severity === "warn" ? `rule "${name}" lowered block \u2192 warn` : `rule "${name}" lowered block \u2192 ${to} (not a valid severity; the rule would no longer block)`
|
|
1195
|
+
);
|
|
1085
1196
|
}
|
|
1086
1197
|
if (ar.enabled === false && br.enabled !== false) {
|
|
1087
1198
|
reasons.push(`rule "${name}" disabled (enabled: false)`);
|
|
@@ -1137,6 +1248,27 @@ function policyWeakening(before, after) {
|
|
|
1137
1248
|
var RULE6 = "hook-tampering";
|
|
1138
1249
|
var HOOK_INVOCATION = /\b(tamperward|npx|npm|pnpm|yarn|bun|jest|vitest|eslint|tsc|lefthook|husky|lint-staged|make|cargo|pytest)\b/;
|
|
1139
1250
|
var isShellComment = (s) => /^\s*#/.test(s);
|
|
1251
|
+
var ownerExec = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
|
|
1252
|
+
var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
|
|
1253
|
+
function chmodTouchesExec(seg) {
|
|
1254
|
+
const toks = tokens(seg).map(unquote);
|
|
1255
|
+
const at = toks.findIndex((t) => /^chmod$/.test(t));
|
|
1256
|
+
if (at === -1) return false;
|
|
1257
|
+
for (const tok of toks.slice(at + 1)) {
|
|
1258
|
+
if (/^[0-7]{3,4}$/.test(tok)) {
|
|
1259
|
+
if (!ownerExec(tok)) return true;
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
for (const clause of tok.split(",")) {
|
|
1263
|
+
const m = clause.match(SYMBOLIC);
|
|
1264
|
+
if (!m) continue;
|
|
1265
|
+
const [, op, perms] = m;
|
|
1266
|
+
const namesExec = /[xX]/.test(perms);
|
|
1267
|
+
if (op === "=" ? !namesExec : namesExec) return true;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1140
1272
|
var hookTampering = {
|
|
1141
1273
|
id: RULE6,
|
|
1142
1274
|
surface: ["file", "command"],
|
|
@@ -1147,6 +1279,16 @@ var hookTampering = {
|
|
|
1147
1279
|
if (c.kind === "file") {
|
|
1148
1280
|
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
|
|
1149
1281
|
if (!targetsHook) continue;
|
|
1282
|
+
if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec(c.oldMode) && !ownerExec(c.newMode)) {
|
|
1283
|
+
out2.push(
|
|
1284
|
+
makeFinding(RULE6, policy, {
|
|
1285
|
+
file: c.path,
|
|
1286
|
+
message: `Execute permission was removed from a protected hook (${c.oldMode} \u2192 ${c.newMode}) \u2014 git will skip it.`,
|
|
1287
|
+
evidence: `${c.path}: mode ${c.oldMode} \u2192 ${c.newMode}`,
|
|
1288
|
+
remediation: "Restore the execute bit (chmod +x). A hook git cannot run is a disabled hook."
|
|
1289
|
+
})
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1150
1292
|
if (c.op === "delete") {
|
|
1151
1293
|
out2.push(
|
|
1152
1294
|
makeFinding(RULE6, policy, {
|
|
@@ -1243,10 +1385,8 @@ var hookTampering = {
|
|
|
1243
1385
|
for (const seg of segments(c.raw)) {
|
|
1244
1386
|
const hitsHook = tokens(seg).map(unquote).some((t) => isProtected(t, policy, "hooks"));
|
|
1245
1387
|
if (!hitsHook) continue;
|
|
1246
|
-
const octal = seg.match(/\bchmod\b[^|;&]*?(?:^|\s)([0-7]{3,4})(?:\s|$)/);
|
|
1247
|
-
const octalDropsExec = octal ? (Number(octal[1].slice(-3)[0]) & 1) === 0 : false;
|
|
1248
1388
|
let why = null;
|
|
1249
|
-
if (
|
|
1389
|
+
if (chmodTouchesExec(seg)) {
|
|
1250
1390
|
why = "chmod alters execute permission on a hook, which can disable it";
|
|
1251
1391
|
} else if (/\brm\b/.test(seg)) {
|
|
1252
1392
|
why = "rm deletes a protected hook";
|
|
@@ -1274,6 +1414,16 @@ var hookTampering = {
|
|
|
1274
1414
|
// src/detectors/test-deletion.ts
|
|
1275
1415
|
import ts3 from "typescript";
|
|
1276
1416
|
var RULE7 = "test-deletion";
|
|
1417
|
+
var TEST_DEFS = {
|
|
1418
|
+
py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
|
|
1419
|
+
go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
|
|
1420
|
+
rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
|
|
1421
|
+
rb: /^\s*(?:(?:it|specify|test|scenario|example)\s*(?:\(\s*)?['"]|def\s+test_\w+)/,
|
|
1422
|
+
java: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
|
|
1423
|
+
kt: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
|
|
1424
|
+
php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
|
|
1425
|
+
cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
|
|
1426
|
+
};
|
|
1277
1427
|
function calleeName(expr) {
|
|
1278
1428
|
if (ts3.isIdentifier(expr)) return expr.text;
|
|
1279
1429
|
if (ts3.isPropertyAccessExpression(expr) && ts3.isIdentifier(expr.expression)) {
|
|
@@ -1281,7 +1431,14 @@ function calleeName(expr) {
|
|
|
1281
1431
|
}
|
|
1282
1432
|
return null;
|
|
1283
1433
|
}
|
|
1284
|
-
function countTestBlocks(src) {
|
|
1434
|
+
function countTestBlocks(src, path = "spec.ts") {
|
|
1435
|
+
const lang = langOf(path);
|
|
1436
|
+
if (lang && lang !== "js") {
|
|
1437
|
+
const re = TEST_DEFS[lang];
|
|
1438
|
+
let n2 = 0;
|
|
1439
|
+
for (const line of src.split("\n")) if (re.test(line)) n2++;
|
|
1440
|
+
return n2;
|
|
1441
|
+
}
|
|
1285
1442
|
const sf = ts3.createSourceFile("spec.ts", src, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
|
|
1286
1443
|
let n = 0;
|
|
1287
1444
|
const visit = (node) => {
|
|
@@ -1294,11 +1451,12 @@ function countTestBlocks(src) {
|
|
|
1294
1451
|
visit(sf);
|
|
1295
1452
|
return n;
|
|
1296
1453
|
}
|
|
1297
|
-
function significantLines(src) {
|
|
1454
|
+
function significantLines(src, path) {
|
|
1298
1455
|
const out2 = /* @__PURE__ */ new Set();
|
|
1456
|
+
const lang = langOf(path);
|
|
1299
1457
|
for (const raw of src.split("\n")) {
|
|
1300
1458
|
const l = raw.trim();
|
|
1301
|
-
if (l
|
|
1459
|
+
if (isSignificantLine(l, lang)) out2.add(l);
|
|
1302
1460
|
}
|
|
1303
1461
|
return out2;
|
|
1304
1462
|
}
|
|
@@ -1312,22 +1470,22 @@ var testDeletion = {
|
|
|
1312
1470
|
let addedTestBlocks = 0;
|
|
1313
1471
|
for (const c of changes) {
|
|
1314
1472
|
if (c.kind === "file" && c.op === "add" && c.after != null && isProtected(c.path, policy, "tests")) {
|
|
1315
|
-
for (const l of significantLines(c.after)) addedTestLines.add(l);
|
|
1316
|
-
addedTestBlocks += countTestBlocks(c.after);
|
|
1473
|
+
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
1474
|
+
addedTestBlocks += countTestBlocks(c.after, c.path);
|
|
1317
1475
|
}
|
|
1318
1476
|
}
|
|
1319
|
-
const isRelocation = (before) => {
|
|
1320
|
-
const sig = significantLines(before);
|
|
1477
|
+
const isRelocation = (before, path) => {
|
|
1478
|
+
const sig = significantLines(before, path);
|
|
1321
1479
|
if (sig.size === 0 || addedTestLines.size === 0) return false;
|
|
1322
1480
|
let hit = 0;
|
|
1323
1481
|
for (const l of sig) if (addedTestLines.has(l)) hit++;
|
|
1324
|
-
return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before);
|
|
1482
|
+
return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before, path);
|
|
1325
1483
|
};
|
|
1326
1484
|
for (const c of changes) {
|
|
1327
1485
|
if (c.kind === "file") {
|
|
1328
1486
|
const isTest = isProtected(c.path, policy, "tests");
|
|
1329
1487
|
if (c.op === "delete" && isTest) {
|
|
1330
|
-
if (c.before != null && isRelocation(c.before)) continue;
|
|
1488
|
+
if (c.before != null && isRelocation(c.before, c.path)) continue;
|
|
1331
1489
|
out2.push(
|
|
1332
1490
|
makeFinding(RULE7, policy, {
|
|
1333
1491
|
file: c.path,
|
|
@@ -1346,8 +1504,8 @@ var testDeletion = {
|
|
|
1346
1504
|
})
|
|
1347
1505
|
);
|
|
1348
1506
|
} else if ((c.op === "modify" || c.op === "rename") && isTest && c.before != null && c.after != null) {
|
|
1349
|
-
const before = countTestBlocks(c.before);
|
|
1350
|
-
const after = countTestBlocks(c.after);
|
|
1507
|
+
const before = countTestBlocks(c.before, c.path);
|
|
1508
|
+
const after = countTestBlocks(c.after, c.path);
|
|
1351
1509
|
if (after < before) {
|
|
1352
1510
|
out2.push(
|
|
1353
1511
|
makeFinding(RULE7, policy, {
|
|
@@ -1403,11 +1561,12 @@ var testDeletion = {
|
|
|
1403
1561
|
var RULE8 = "test-content-removal";
|
|
1404
1562
|
var MIN_REMOVED_LINES = 3;
|
|
1405
1563
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
1406
|
-
function significantLinesOrdered(src) {
|
|
1564
|
+
function significantLinesOrdered(src, path) {
|
|
1407
1565
|
const out2 = [];
|
|
1566
|
+
const lang = langOf(path);
|
|
1408
1567
|
for (const raw of src.split("\n")) {
|
|
1409
1568
|
const l = raw.trim();
|
|
1410
|
-
if (l
|
|
1569
|
+
if (isSignificantLine(l, lang)) out2.push(l);
|
|
1411
1570
|
}
|
|
1412
1571
|
return out2;
|
|
1413
1572
|
}
|
|
@@ -1420,16 +1579,16 @@ var testContentRemoval = {
|
|
|
1420
1579
|
let keptPool = "";
|
|
1421
1580
|
for (const c of changes) {
|
|
1422
1581
|
if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
|
|
1423
|
-
keptPool += ws(significantLinesOrdered(c.after).join("\n"));
|
|
1582
|
+
keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
|
|
1424
1583
|
}
|
|
1425
1584
|
}
|
|
1426
1585
|
for (const c of changes) {
|
|
1427
1586
|
if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
|
|
1428
1587
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
1429
1588
|
if (isProtected(c.path, policy, "snapshots")) continue;
|
|
1430
|
-
if (countTestBlocks(c.after) < countTestBlocks(c.before)) continue;
|
|
1431
|
-
const beforeSig = significantLinesOrdered(c.before);
|
|
1432
|
-
const afterSig = significantLinesOrdered(c.after);
|
|
1589
|
+
if (countTestBlocks(c.after, c.path) < countTestBlocks(c.before, c.path)) continue;
|
|
1590
|
+
const beforeSig = significantLinesOrdered(c.before, c.path);
|
|
1591
|
+
const afterSig = significantLinesOrdered(c.after, c.path);
|
|
1433
1592
|
const netDrop = beforeSig.length - afterSig.length;
|
|
1434
1593
|
const afterSet = new Set(afterSig);
|
|
1435
1594
|
const gone = [];
|
|
@@ -1600,6 +1759,7 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
|
|
|
1600
1759
|
return true;
|
|
1601
1760
|
});
|
|
1602
1761
|
return deduped.filter((f) => {
|
|
1762
|
+
if (f.rule !== "detector-error" && !isEnabled(f.rule, policy)) return false;
|
|
1603
1763
|
const ex = policy.rules[f.rule]?.exclude;
|
|
1604
1764
|
if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
|
|
1605
1765
|
return !matchesAny(f.file, ex);
|
|
@@ -1622,9 +1782,62 @@ function normalizeVersion(v, where = POLICY_FILE) {
|
|
|
1622
1782
|
}
|
|
1623
1783
|
return v;
|
|
1624
1784
|
}
|
|
1625
|
-
|
|
1785
|
+
var SEVERITIES = ["block", "warn"];
|
|
1786
|
+
var isSeverity = (v) => SEVERITIES.includes(v);
|
|
1787
|
+
var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
1788
|
+
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1789
|
+
function validate(r, where) {
|
|
1790
|
+
function bad(msg) {
|
|
1791
|
+
throw new PolicyError(`${where}: ${msg}`);
|
|
1792
|
+
}
|
|
1793
|
+
const show = (v) => JSON.stringify(v) ?? String(v);
|
|
1794
|
+
if (r.rules !== void 0) {
|
|
1795
|
+
if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
|
|
1796
|
+
for (const [name, cfg] of Object.entries(r.rules)) {
|
|
1797
|
+
if (!isMapping(cfg)) bad(`rules.${name} must be a mapping like { severity: block }, got ${show(cfg)}`);
|
|
1798
|
+
if (cfg.severity !== void 0 && !isSeverity(cfg.severity)) {
|
|
1799
|
+
bad(`rules.${name}.severity must be "block" or "warn", got ${show(cfg.severity)}`);
|
|
1800
|
+
}
|
|
1801
|
+
if (cfg.enabled !== void 0 && typeof cfg.enabled !== "boolean") {
|
|
1802
|
+
bad(`rules.${name}.enabled must be true or false, got ${show(cfg.enabled)}`);
|
|
1803
|
+
}
|
|
1804
|
+
if (cfg.exclude !== void 0 && !isStringList(cfg.exclude)) {
|
|
1805
|
+
bad(`rules.${name}.exclude must be a list of globs, got ${show(cfg.exclude)}`);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
1810
|
+
if (r.protected !== void 0) {
|
|
1811
|
+
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
1812
|
+
for (const [cat, globs] of Object.entries(r.protected)) {
|
|
1813
|
+
if (!isStringList(globs)) bad(`protected.${cat} must be a list of globs, got ${show(globs)}`);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
if (r.signoff !== void 0) {
|
|
1817
|
+
if (!isMapping(r.signoff)) bad(`signoff must be a mapping, got ${show(r.signoff)}`);
|
|
1818
|
+
for (const key2 of ["required_for", "requiredFor"]) {
|
|
1819
|
+
const v = r.signoff[key2];
|
|
1820
|
+
if (v !== void 0 && !(Array.isArray(v) && v.every(isSeverity))) {
|
|
1821
|
+
bad(`signoff.${key2} must be a list of "block" / "warn", got ${show(v)}`);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
const ledger = r.signoff.ledger;
|
|
1825
|
+
if (ledger !== void 0 && typeof ledger !== "string") bad(`signoff.ledger must be a path, got ${show(ledger)}`);
|
|
1826
|
+
}
|
|
1827
|
+
if (r.verify !== void 0) {
|
|
1828
|
+
if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
|
|
1829
|
+
const v = r.verify;
|
|
1830
|
+
if (v.command !== void 0 && typeof v.command !== "string") bad(`verify.command must be a string, got ${show(v.command)}`);
|
|
1831
|
+
if (v.budget !== void 0 && !(typeof v.budget === "number" && Number.isFinite(v.budget) && v.budget > 0)) {
|
|
1832
|
+
bad(`verify.budget must be a positive number of seconds, got ${show(v.budget)}`);
|
|
1833
|
+
}
|
|
1834
|
+
if (v.inputs !== void 0 && !isStringList(v.inputs)) bad(`verify.inputs must be a list of globs, got ${show(v.inputs)}`);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
function parsePolicy(raw, where = POLICY_FILE) {
|
|
1626
1838
|
const r = raw ?? {};
|
|
1627
|
-
const version = normalizeVersion(r.version);
|
|
1839
|
+
const version = normalizeVersion(r.version, where);
|
|
1840
|
+
validate(r, where);
|
|
1628
1841
|
const base = defaultPolicy(version);
|
|
1629
1842
|
return {
|
|
1630
1843
|
version,
|
|
@@ -1660,7 +1873,7 @@ function parseOrThrow(src, where) {
|
|
|
1660
1873
|
if (raw !== null && raw !== void 0 && (typeof raw !== "object" || Array.isArray(raw))) {
|
|
1661
1874
|
throw new PolicyError(`${where} is not a policy mapping`);
|
|
1662
1875
|
}
|
|
1663
|
-
return parsePolicy(raw);
|
|
1876
|
+
return parsePolicy(raw, where);
|
|
1664
1877
|
}
|
|
1665
1878
|
function loadPolicy(cwd = process.cwd()) {
|
|
1666
1879
|
const path = join2(cwd, POLICY_FILE);
|
|
@@ -2044,7 +2257,7 @@ import { createHash as createHash4 } from "node:crypto";
|
|
|
2044
2257
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2045
2258
|
import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
2046
2259
|
import { tmpdir } from "node:os";
|
|
2047
|
-
import { join as join4,
|
|
2260
|
+
import { isAbsolute, join as join4, relative, resolve } from "node:path";
|
|
2048
2261
|
function asStr(v) {
|
|
2049
2262
|
return typeof v === "string" ? v : "";
|
|
2050
2263
|
}
|
|
@@ -2056,10 +2269,11 @@ function readDisk(path) {
|
|
|
2056
2269
|
}
|
|
2057
2270
|
}
|
|
2058
2271
|
function abs(path, cwd) {
|
|
2059
|
-
return
|
|
2272
|
+
return resolve(cwd, path);
|
|
2060
2273
|
}
|
|
2061
2274
|
function relForDisplay(path, cwd) {
|
|
2062
|
-
|
|
2275
|
+
const rel = relative(cwd, path);
|
|
2276
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
|
|
2063
2277
|
}
|
|
2064
2278
|
function applyEdit(content, oldStr, newStr) {
|
|
2065
2279
|
if (content === null) return newStr;
|
|
@@ -2080,9 +2294,15 @@ function synthFileChange(displayPath, before, after) {
|
|
|
2080
2294
|
writeFileSync(a, before ?? "");
|
|
2081
2295
|
writeFileSync(b, after ?? "");
|
|
2082
2296
|
try {
|
|
2083
|
-
raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], {
|
|
2297
|
+
raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], {
|
|
2298
|
+
encoding: "utf8",
|
|
2299
|
+
maxBuffer: 256 * 1024 * 1024
|
|
2300
|
+
});
|
|
2084
2301
|
} catch (e) {
|
|
2085
2302
|
const err2 = e;
|
|
2303
|
+
if (err2.status !== 1) {
|
|
2304
|
+
throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
|
|
2305
|
+
}
|
|
2086
2306
|
raw = err2.stdout ? String(err2.stdout) : "";
|
|
2087
2307
|
}
|
|
2088
2308
|
} finally {
|
|
@@ -2197,13 +2417,19 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
2197
2417
|
// src/effect.ts
|
|
2198
2418
|
import { createHash as createHash2 } from "node:crypto";
|
|
2199
2419
|
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2200
|
-
import { join as join6 } from "node:path";
|
|
2420
|
+
import { isAbsolute as isAbsolute2, join as join6 } from "node:path";
|
|
2201
2421
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2202
2422
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
2203
2423
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
2204
2424
|
function gitDir2(cwd) {
|
|
2205
2425
|
try {
|
|
2206
|
-
|
|
2426
|
+
const d = execFileSync3("git", ["rev-parse", "--git-dir"], {
|
|
2427
|
+
cwd,
|
|
2428
|
+
encoding: "utf8",
|
|
2429
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2430
|
+
}).trim();
|
|
2431
|
+
if (!d) return null;
|
|
2432
|
+
return isAbsolute2(d) ? d : join6(cwd, d);
|
|
2207
2433
|
} catch {
|
|
2208
2434
|
return null;
|
|
2209
2435
|
}
|
|
@@ -2212,7 +2438,7 @@ function ptreePath(cwd, sessionId) {
|
|
|
2212
2438
|
if (!sessionId) return null;
|
|
2213
2439
|
const gd = gitDir2(cwd);
|
|
2214
2440
|
if (!gd) return null;
|
|
2215
|
-
const dir = join6(
|
|
2441
|
+
const dir = join6(gd, "tamperward");
|
|
2216
2442
|
return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
2217
2443
|
}
|
|
2218
2444
|
var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
|
|
@@ -2295,7 +2521,8 @@ function snap(abs2) {
|
|
|
2295
2521
|
}
|
|
2296
2522
|
}
|
|
2297
2523
|
function defaultEventLog(cwd) {
|
|
2298
|
-
|
|
2524
|
+
if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
|
|
2525
|
+
return join7(gitDir(cwd) ?? join7(cwd, ".git"), "tamperward", "fsevents.jsonl");
|
|
2299
2526
|
}
|
|
2300
2527
|
function watchTree(dir, cb) {
|
|
2301
2528
|
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
@@ -2405,6 +2632,7 @@ function readEvents(log, offset) {
|
|
|
2405
2632
|
return { events, newOffset: size };
|
|
2406
2633
|
}
|
|
2407
2634
|
function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
2635
|
+
if (!isEnabled(RULE11, policy)) return [];
|
|
2408
2636
|
const byPath = /* @__PURE__ */ new Map();
|
|
2409
2637
|
for (const e of events) {
|
|
2410
2638
|
if (!isProtected(e.path, policy)) continue;
|
|
@@ -2721,11 +2949,12 @@ import { createHash as createHash5 } from "node:crypto";
|
|
|
2721
2949
|
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2722
2950
|
import { dirname as dirname3, join as join8 } from "node:path";
|
|
2723
2951
|
import { fileURLToPath } from "node:url";
|
|
2724
|
-
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2725
2952
|
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
2726
|
-
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2727
|
-
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
2728
2953
|
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2954
|
+
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
2955
|
+
var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
|
|
2956
|
+
var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
|
|
2957
|
+
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
2729
2958
|
var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
|
|
2730
2959
|
var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
|
|
2731
2960
|
function coveredBy(existing, critical) {
|
|
@@ -2781,6 +3010,17 @@ function shippedVersion() {
|
|
|
2781
3010
|
return "latest";
|
|
2782
3011
|
}
|
|
2783
3012
|
var TW_VERSION = shippedVersion();
|
|
3013
|
+
var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
3014
|
+
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
3015
|
+
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
3016
|
+
function pinOf(command) {
|
|
3017
|
+
const m = command.match(OURS);
|
|
3018
|
+
return m ? m[1] ?? "" : null;
|
|
3019
|
+
}
|
|
3020
|
+
function stalePin(command) {
|
|
3021
|
+
const pin = pinOf(command);
|
|
3022
|
+
return pin !== null && pin !== TW_VERSION;
|
|
3023
|
+
}
|
|
2784
3024
|
var WORKFLOW_CONTENT = `name: tamperward
|
|
2785
3025
|
|
|
2786
3026
|
# The CI authority for main: the same engine as the agent hook and pre-commit, run over
|
|
@@ -2901,15 +3141,18 @@ function planClaudeHooks(cwd) {
|
|
|
2901
3141
|
settings = parsed;
|
|
2902
3142
|
}
|
|
2903
3143
|
const hooks = settings.hooks ??= {};
|
|
2904
|
-
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => String(h.command ?? "")
|
|
2905
|
-
const preEntries = entriesWith(hooks.PreToolUse,
|
|
3144
|
+
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
|
|
3145
|
+
const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
|
|
2906
3146
|
const needPre = preEntries.length === 0;
|
|
2907
|
-
const
|
|
3147
|
+
const stopEntries = entriesWith(hooks.Stop, SWEEP_RE);
|
|
3148
|
+
const needStop = stopEntries.length === 0;
|
|
2908
3149
|
const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
|
|
2909
|
-
|
|
3150
|
+
const repin = [...preEntries, ...stopEntries].flatMap((m) => m.hooks ?? []).filter((h) => stalePin(String(h.command ?? "")));
|
|
3151
|
+
if (!needPre && !needStop && stale.length === 0 && repin.length === 0) {
|
|
2910
3152
|
return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
2911
3153
|
}
|
|
2912
3154
|
const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
|
|
3155
|
+
const pins = [...new Set(repin.map((h) => pinOf(String(h.command ?? "")) || "unpinned"))];
|
|
2913
3156
|
return {
|
|
2914
3157
|
item: "agent",
|
|
2915
3158
|
path: rel,
|
|
@@ -2917,9 +3160,14 @@ function planClaudeHooks(cwd) {
|
|
|
2917
3160
|
detail: [
|
|
2918
3161
|
needPre && "wire PreToolUse deny",
|
|
2919
3162
|
needStop && "wire Stop sweep",
|
|
2920
|
-
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}
|
|
3163
|
+
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`,
|
|
3164
|
+
repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})`
|
|
2921
3165
|
].filter(Boolean).join(" + "),
|
|
2922
3166
|
apply: () => {
|
|
3167
|
+
for (const h of repin) {
|
|
3168
|
+
const kind = String(h.command ?? "").match(OURS)?.[2];
|
|
3169
|
+
h.command = kind === "hook claude" ? HOOK_CMD : kind === "sweep claude" ? SWEEP_CMD : h.command;
|
|
3170
|
+
}
|
|
2923
3171
|
if (needPre) {
|
|
2924
3172
|
(hooks.PreToolUse ??= []).push({
|
|
2925
3173
|
matcher: PRE_MATCHER,
|
|
@@ -2947,8 +3195,24 @@ ${PRECOMMIT_CMD}
|
|
|
2947
3195
|
if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
|
|
2948
3196
|
const path = join8(cwd, target.rel);
|
|
2949
3197
|
const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
|
|
2950
|
-
if (existing
|
|
2951
|
-
|
|
3198
|
+
if (existing !== null && PRECOMMIT_RE.test(existing)) {
|
|
3199
|
+
const lines = existing.split("\n");
|
|
3200
|
+
const staleAt = lines.findIndex((l) => PRECOMMIT_RE.test(l) && stalePin(l));
|
|
3201
|
+
if (staleAt === -1) {
|
|
3202
|
+
return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
|
|
3203
|
+
}
|
|
3204
|
+
const was = pinOf(lines[staleAt]) || "unpinned";
|
|
3205
|
+
return {
|
|
3206
|
+
item: "pre-commit",
|
|
3207
|
+
path: target.rel,
|
|
3208
|
+
status: "update",
|
|
3209
|
+
detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
|
|
3210
|
+
apply: () => {
|
|
3211
|
+
lines[staleAt] = PRECOMMIT_CMD;
|
|
3212
|
+
writeFileSync5(path, lines.join("\n"));
|
|
3213
|
+
chmodSync(path, 493);
|
|
3214
|
+
}
|
|
3215
|
+
};
|
|
2952
3216
|
}
|
|
2953
3217
|
return {
|
|
2954
3218
|
item: "pre-commit",
|
|
@@ -3084,13 +3348,13 @@ import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
|
|
|
3084
3348
|
import { createHash as createHash8 } from "node:crypto";
|
|
3085
3349
|
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync6, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
|
|
3086
3350
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
3087
|
-
import { dirname as dirname4, join as join11, resolve } from "node:path";
|
|
3351
|
+
import { dirname as dirname4, join as join11, resolve as resolve2 } from "node:path";
|
|
3088
3352
|
|
|
3089
3353
|
// src/git/trusted.ts
|
|
3090
3354
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3091
3355
|
import { createHash as createHash6 } from "node:crypto";
|
|
3092
3356
|
import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
|
|
3093
|
-
import { isAbsolute as
|
|
3357
|
+
import { isAbsolute as isAbsolute3, join as join9 } from "node:path";
|
|
3094
3358
|
function trustedGitEnv() {
|
|
3095
3359
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
3096
3360
|
}
|
|
@@ -3103,7 +3367,7 @@ function gitDirOf(cwd) {
|
|
|
3103
3367
|
env: trustedGitEnv()
|
|
3104
3368
|
}).trim();
|
|
3105
3369
|
if (!d) return null;
|
|
3106
|
-
return
|
|
3370
|
+
return isAbsolute3(d) ? d : join9(cwd, d);
|
|
3107
3371
|
} catch {
|
|
3108
3372
|
return null;
|
|
3109
3373
|
}
|
|
@@ -3268,7 +3532,7 @@ function materialize(cwd, dest) {
|
|
|
3268
3532
|
}
|
|
3269
3533
|
chmodSync2(out2, st.mode);
|
|
3270
3534
|
}
|
|
3271
|
-
const nm =
|
|
3535
|
+
const nm = resolve2(cwd, "node_modules");
|
|
3272
3536
|
if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
|
|
3273
3537
|
}
|
|
3274
3538
|
function dropSymlink(p) {
|
|
@@ -3349,18 +3613,52 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
3349
3613
|
}
|
|
3350
3614
|
return { restored, removed };
|
|
3351
3615
|
}
|
|
3616
|
+
var SUPERVISOR = `
|
|
3617
|
+
const cp = require('node:child_process');
|
|
3618
|
+
const fs = require('node:fs');
|
|
3619
|
+
const [cmd, budgetMs, outFile] = process.argv.slice(1);
|
|
3620
|
+
const child = cp.spawn('sh', ['-c', cmd], { stdio: 'ignore', detached: process.platform !== 'win32' });
|
|
3621
|
+
const killGroup = () => {
|
|
3622
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch {}
|
|
3623
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
3624
|
+
};
|
|
3625
|
+
let done = false;
|
|
3626
|
+
const finish = (r) => {
|
|
3627
|
+
if (done) return;
|
|
3628
|
+
done = true;
|
|
3629
|
+
killGroup();
|
|
3630
|
+
fs.writeFileSync(outFile, JSON.stringify(r));
|
|
3631
|
+
process.exit(0);
|
|
3632
|
+
};
|
|
3633
|
+
const timer = setTimeout(() => finish({ timedOut: true }), Number(budgetMs));
|
|
3634
|
+
child.on('error', (e) => { clearTimeout(timer); finish({ error: String(e) }); });
|
|
3635
|
+
child.on('exit', (code, signal) => { clearTimeout(timer); finish({ exit: code, signal }); });
|
|
3636
|
+
`;
|
|
3352
3637
|
function runSuite(dir, cmd, budgetSecs) {
|
|
3353
3638
|
const t0 = Date.now();
|
|
3354
|
-
const
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3639
|
+
const outDir = mkdtempSync2(join11(tmpdir2(), "tw-verify-run-"));
|
|
3640
|
+
const outFile = join11(outDir, "result.json");
|
|
3641
|
+
try {
|
|
3642
|
+
spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
3643
|
+
cwd: dir,
|
|
3644
|
+
stdio: "ignore",
|
|
3645
|
+
timeout: budgetSecs * 1e3 + 3e4,
|
|
3646
|
+
// backstop only; the supervisor enforces the budget
|
|
3647
|
+
killSignal: "SIGKILL"
|
|
3648
|
+
});
|
|
3649
|
+
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
3650
|
+
let r;
|
|
3651
|
+
try {
|
|
3652
|
+
r = JSON.parse(readFileSync13(outFile, "utf8"));
|
|
3653
|
+
} catch {
|
|
3654
|
+
return { exit: null, secs };
|
|
3655
|
+
}
|
|
3656
|
+
if (r.timedOut || r.error) return { exit: null, secs };
|
|
3657
|
+
if (r.exit === null || r.exit === void 0) return { exit: null, secs };
|
|
3658
|
+
return { exit: r.exit, secs };
|
|
3659
|
+
} finally {
|
|
3660
|
+
rmSync2(outDir, { recursive: true, force: true });
|
|
3661
|
+
}
|
|
3364
3662
|
}
|
|
3365
3663
|
function runVerify(opts) {
|
|
3366
3664
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -3517,7 +3815,7 @@ function parseVerify(args) {
|
|
|
3517
3815
|
// src/cli/run.ts
|
|
3518
3816
|
import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
|
|
3519
3817
|
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
|
|
3520
|
-
import { resolve as
|
|
3818
|
+
import { resolve as resolve3 } from "node:path";
|
|
3521
3819
|
var out = (s) => process.stdout.write(s + "\n");
|
|
3522
3820
|
var err = (s) => process.stderr.write(s + "\n");
|
|
3523
3821
|
function git3(args, cwd) {
|
|
@@ -3564,7 +3862,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3564
3862
|
return out2;
|
|
3565
3863
|
}
|
|
3566
3864
|
function runEnvelope(opts) {
|
|
3567
|
-
const cwd =
|
|
3865
|
+
const cwd = resolve3(opts.cwd ?? process.cwd());
|
|
3568
3866
|
if (!opts.argv.length) {
|
|
3569
3867
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
3570
3868
|
return 2;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"NOTICE"
|
|
38
38
|
],
|
|
39
39
|
"engines": {
|
|
40
|
-
"node": ">=
|
|
40
|
+
"node": ">=20.19"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "esbuild src/cli/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/cli/index.js",
|
|
@@ -53,10 +53,10 @@
|
|
|
53
53
|
"yaml": "^2.6.1"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@types/node": "^20.
|
|
56
|
+
"@types/node": "^22.20.1",
|
|
57
57
|
"@types/picomatch": "^3.0.1",
|
|
58
|
-
"esbuild": "^0.
|
|
59
|
-
"vitepress": "^
|
|
60
|
-
"vitest": "^
|
|
58
|
+
"esbuild": "^0.25.12",
|
|
59
|
+
"vitepress": "^2.0.0-alpha.19",
|
|
60
|
+
"vitest": "^4.1.11"
|
|
61
61
|
}
|
|
62
62
|
}
|