tamperward 2.17.1 → 2.18.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 +28 -0
- package/dist/cli/index.js +431 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -240,6 +240,7 @@ platforms.
|
|
|
240
240
|
| `watch` / observer telemetry | Supported; backend health is reported | Supported/degraded according to `fs.watch` health | Supported/degraded according to `fs.watch` health |
|
|
241
241
|
| checkpointed-local `verify` | Supported via `/bin/sh` | Supported via `/bin/sh` | **Unsupported; fails before candidate execution** |
|
|
242
242
|
| isolated-container `verify` | Supported when Docker authority preflight passes | Not claimed beyond Docker preflight | Not claimed beyond Docker preflight |
|
|
243
|
+
| advisory `trace-verify` | **Supported with `strace` + `tar`** | **Unsupported; reports no parity** | **Unsupported; reports no parity** |
|
|
243
244
|
| authoritative `run` | **Supported only with trusted non-root subreaper backend** | **Unsupported; fails before agent start** | **Unsupported; fails before agent start** |
|
|
244
245
|
| CI coverage for this contract | Full suite + platform contract | Platform-contract job | Platform-contract job |
|
|
245
246
|
|
|
@@ -430,6 +431,30 @@ restored `"test": "sh scripts/test.sh"` will happily call a script nothing
|
|
|
430
431
|
restored. It bounds the class rather than closing it — see
|
|
431
432
|
[the threat model](./docs/THREAT-MODEL-pristine-run.md).
|
|
432
433
|
|
|
434
|
+
From **2.18.0**, Linux can turn that residual into an auditable observation with
|
|
435
|
+
`tamperward trace-verify`. It materialises the caller-selected trusted base, runs the
|
|
436
|
+
known-good verifier under `strace`, repeats the trace (two runs by default), unions the
|
|
437
|
+
file/exec observations, and reports:
|
|
438
|
+
- tracked repository inputs the verifier actually read or executed;
|
|
439
|
+
- likely config inputs;
|
|
440
|
+
- external dependency/runtime paths;
|
|
441
|
+
- paths seen in only some runs as **dynamic**;
|
|
442
|
+
- whether each tracked input is already covered by the same pristine-verification
|
|
443
|
+
surface used by `verify`;
|
|
444
|
+
- exact uncovered paths as candidate `verify.inputs` entries for **human review**.
|
|
445
|
+
|
|
446
|
+
It is advisory only: it never edits `.tamperward.yml`, never widens a glob, and never
|
|
447
|
+
treats absence from one or several traces as proof a path can never be read. Use a
|
|
448
|
+
known-good base; `trace-verify` observes what those executions did, it does not prove
|
|
449
|
+
the base or external runtime/dependencies are trustworthy. macOS and Windows report the
|
|
450
|
+
feature unsupported rather than implying parity.
|
|
451
|
+
|
|
452
|
+
Example:
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
npx tamperward trace-verify --base main --cmd "npm test" --runs 3
|
|
456
|
+
```
|
|
457
|
+
|
|
433
458
|
From **2.16.0**, verifier suite output is diagnostic evidence instead of discarded
|
|
434
459
|
noise. Both visible and pristine stages continuously drain stdout/stderr through a
|
|
435
460
|
trusted supervisor, retain only the final **16 KiB per stream**, and count the total
|
|
@@ -448,6 +473,7 @@ The four primitives:
|
|
|
448
473
|
npx tamperward check --staged # pre-commit view
|
|
449
474
|
npx tamperward check --diff "main...HEAD" # CI view over the PR's commit range
|
|
450
475
|
npx tamperward verify --base main # pristine-suite re-execution
|
|
476
|
+
npx tamperward trace-verify --base main --runs 2 # advisory observed-input discovery (Linux)
|
|
451
477
|
npx tamperward run --agent-budget 1800 -- <agent command...> # optional agent-runtime bound
|
|
452
478
|
```
|
|
453
479
|
|
|
@@ -470,6 +496,7 @@ option can never be reinterpreted as the agent command.
|
|
|
470
496
|
| --- | --- |
|
|
471
497
|
| `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
|
|
472
498
|
| `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>` |
|
|
499
|
+
| `trace-verify` | Linux-only advisory discovery: `--base <rev>` (default `HEAD`) · `--cmd <suite command>` · `--budget <seconds>` · `--runs <positive integer>` (default 2) · `--json` · `--cwd <dir>` |
|
|
473
500
|
| `doctor` | `--base <rev>` (trusted policy revision) · `--workflow <path>` · `--cwd <dir>` · `--json` · `--github` · `--repo <owner/repo>` · `--branch <name>` — read-only installation/authority posture plus CI verifier outer-time validation |
|
|
474
501
|
| `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` (per verifier suite) · `--agent-budget <seconds>` (optional wrapped-agent wall clock) · `--observe-transients` (start a session-scoped transient observer) · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
|
|
475
502
|
| `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
|
|
@@ -483,6 +510,7 @@ option can never be reinterpreted as the agent command.
|
|
|
483
510
|
| --- | --- | --- | --- | --- |
|
|
484
511
|
| `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, no view given, not a git repository, or an unresolvable revision — any failure the gate cannot recover from is one clean `tamperward: …` line on stderr at exit 2, never a stack trace at exit 1 | — |
|
|
485
512
|
| `verify` | `VERIFIED` — visible and pristine both green; or a `MASKED_FAILURE` cleared by an out-of-band `verify@<head-sha>` approval | `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 | — |
|
|
513
|
+
| `trace-verify` | all requested known-good traces exited 0; advisory report emitted | one or more traced verifier runs were non-zero/incomplete; report still emitted | unsupported platform, missing tracer/materialiser, bad base/policy/options, or tracing failure | — |
|
|
486
514
|
| `doctor` | configured verify job(s) have sufficient static outer time for the trusted policy | — | missing/invalid workflow, no verify job, missing/malformed/insufficient timeout, or trusted policy cannot be loaded | — |
|
|
487
515
|
| `run` | enforcement clean and the agent exited 0 — another non-zero agent exit is passed through unchanged | any blocking finding or masked failure, including a non-quiescent process after timeout | cannot adjudicate: dirty start, policy error, verify cannot run | `AGENT_TIMEOUT`: `--agent-budget` expired and post-timeout enforcement was clean |
|
|
488
516
|
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name | — |
|
package/dist/cli/index.js
CHANGED
|
@@ -2010,8 +2010,8 @@ function numericOf(e) {
|
|
|
2010
2010
|
}
|
|
2011
2011
|
return void 0;
|
|
2012
2012
|
}
|
|
2013
|
-
function metricsOf(expr,
|
|
2014
|
-
const obj =
|
|
2013
|
+
function metricsOf(expr, resolve10) {
|
|
2014
|
+
const obj = resolve10(expr);
|
|
2015
2015
|
if (!ts3.isObjectLiteralExpression(obj)) return void 0;
|
|
2016
2016
|
const out2 = { values: {}, opaque: false };
|
|
2017
2017
|
let any = false;
|
|
@@ -2025,7 +2025,7 @@ function metricsOf(expr, resolve9) {
|
|
|
2025
2025
|
const k = keyName(p.name);
|
|
2026
2026
|
if (k === null || !isMetric(k)) continue;
|
|
2027
2027
|
any = true;
|
|
2028
|
-
const n = numericOf(
|
|
2028
|
+
const n = numericOf(resolve10(p.initializer));
|
|
2029
2029
|
if (n === void 0) out2.opaque = true;
|
|
2030
2030
|
else out2.values[k] = n;
|
|
2031
2031
|
}
|
|
@@ -2071,17 +2071,17 @@ function parseThresholds(src) {
|
|
|
2071
2071
|
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
2072
2072
|
try {
|
|
2073
2073
|
const sf = sourceOf(src);
|
|
2074
|
-
const
|
|
2074
|
+
const resolve10 = resolverFor(sf);
|
|
2075
2075
|
const visit = (node) => {
|
|
2076
2076
|
if (ts3.isPropertyAssignment(node)) {
|
|
2077
2077
|
const key2 = keyName(node.name);
|
|
2078
2078
|
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
2079
2079
|
res.present = true;
|
|
2080
|
-
const init =
|
|
2080
|
+
const init = resolve10(node.initializer);
|
|
2081
2081
|
if (!ts3.isObjectLiteralExpression(init)) {
|
|
2082
2082
|
res.opaque = true;
|
|
2083
2083
|
} else {
|
|
2084
|
-
const flat = metricsOf(init,
|
|
2084
|
+
const flat = metricsOf(init, resolve10);
|
|
2085
2085
|
if (flat) res.global = merge(res.global, flat);
|
|
2086
2086
|
for (const p of init.properties) {
|
|
2087
2087
|
if (ts3.isSpreadAssignment(p)) {
|
|
@@ -2095,7 +2095,7 @@ function parseThresholds(src) {
|
|
|
2095
2095
|
res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
|
|
2096
2096
|
continue;
|
|
2097
2097
|
}
|
|
2098
|
-
const m = metricsOf(p.initializer,
|
|
2098
|
+
const m = metricsOf(p.initializer, resolve10);
|
|
2099
2099
|
if (!m) continue;
|
|
2100
2100
|
if (k === "global") res.global = merge(res.global, m);
|
|
2101
2101
|
else res.paths.set(norm(k), m);
|
|
@@ -2128,18 +2128,18 @@ function parseLists(src) {
|
|
|
2128
2128
|
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
2129
2129
|
try {
|
|
2130
2130
|
const sf = sourceOf(src);
|
|
2131
|
-
const
|
|
2131
|
+
const resolve10 = resolverFor(sf);
|
|
2132
2132
|
const visit = (node) => {
|
|
2133
2133
|
if (ts3.isPropertyAssignment(node)) {
|
|
2134
2134
|
const k = keyName(node.name);
|
|
2135
2135
|
const vitestCoverage = ownerKey(node) === "coverage";
|
|
2136
2136
|
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
2137
|
-
const { items, opaque } = literals(
|
|
2137
|
+
const { items, opaque } = literals(resolve10(node.initializer));
|
|
2138
2138
|
res.collectFrom = items;
|
|
2139
2139
|
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
2140
2140
|
if (opaque) res.opaqueCollect = true;
|
|
2141
2141
|
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
2142
|
-
const { items, opaque } = literals(
|
|
2142
|
+
const { items, opaque } = literals(resolve10(node.initializer));
|
|
2143
2143
|
res.ignore.push(...items);
|
|
2144
2144
|
if (opaque) res.opaqueIgnore = true;
|
|
2145
2145
|
}
|
|
@@ -10695,6 +10695,17 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
10695
10695
|
}
|
|
10696
10696
|
return picked;
|
|
10697
10697
|
}
|
|
10698
|
+
function verifierCoveredInputs(cmd, atBase, policy) {
|
|
10699
|
+
const verifierOwned = verifierInputs(cmd, atBase, policy);
|
|
10700
|
+
const verifierGlobs = policy.verify?.inputs ?? [];
|
|
10701
|
+
const covered = /* @__PURE__ */ new Set();
|
|
10702
|
+
for (const path of atBase) {
|
|
10703
|
+
if (OVERLAY_CLASSES.some((category) => isProtected(path, policy, category)) || matchesAny(path, VERIFICATION_SURFACE) || verifierOwned.has(path) || verifierGlobs.length > 0 && matchesAny(path, verifierGlobs)) {
|
|
10704
|
+
covered.add(path);
|
|
10705
|
+
}
|
|
10706
|
+
}
|
|
10707
|
+
return covered;
|
|
10708
|
+
}
|
|
10698
10709
|
function baseEntries(base, cwd) {
|
|
10699
10710
|
return git2(["ls-tree", "-r", "-z", base], cwd).split("\0").filter(Boolean).map((rec) => {
|
|
10700
10711
|
const tab = rec.indexOf(" ");
|
|
@@ -10722,9 +10733,12 @@ function overlayDigest(dest, paths) {
|
|
|
10722
10733
|
function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
|
|
10723
10734
|
const entries = baseEntries(base, cwd);
|
|
10724
10735
|
const atBase = entries.map((e) => e.path);
|
|
10725
|
-
const verifierOwned = verifierInputs(cmd, atBase, policy);
|
|
10726
10736
|
const verifierGlobs = policy.verify?.inputs ?? [];
|
|
10727
|
-
const
|
|
10737
|
+
const coveredBaseInputs = verifierCoveredInputs(cmd, atBase, policy);
|
|
10738
|
+
const isOverlay = (p) => coveredBaseInputs.has(p) || // Candidate-added files do not exist in atBase and therefore cannot appear
|
|
10739
|
+
// in the precomputed set. They still must be removed when they land on a
|
|
10740
|
+
// protected class or the built-in verification surface.
|
|
10741
|
+
OVERLAY_CLASSES.some((category) => isProtected(p, policy, category)) || matchesAny(p, VERIFICATION_SURFACE) || verifierGlobs.length > 0 && matchesAny(p, verifierGlobs);
|
|
10728
10742
|
const restored = [];
|
|
10729
10743
|
const restoredLinks = [];
|
|
10730
10744
|
const baseProtected = /* @__PURE__ */ new Set();
|
|
@@ -12627,6 +12641,384 @@ function runDoctor(opts = {}) {
|
|
|
12627
12641
|
return 0;
|
|
12628
12642
|
}
|
|
12629
12643
|
|
|
12644
|
+
// src/cli/trace-verify.ts
|
|
12645
|
+
import { execFileSync as execFileSync11, spawnSync as spawnSync5 } from "node:child_process";
|
|
12646
|
+
import {
|
|
12647
|
+
existsSync as existsSync13,
|
|
12648
|
+
mkdtempSync as mkdtempSync5,
|
|
12649
|
+
readFileSync as readFileSync18,
|
|
12650
|
+
readdirSync as readdirSync6,
|
|
12651
|
+
realpathSync as realpathSync7,
|
|
12652
|
+
rmSync as rmSync6,
|
|
12653
|
+
symlinkSync as symlinkSync2
|
|
12654
|
+
} from "node:fs";
|
|
12655
|
+
import { tmpdir as tmpdir5 } from "node:os";
|
|
12656
|
+
import { isAbsolute as isAbsolute11, join as join19, relative as relative6, resolve as resolve9, sep as sep3 } from "node:path";
|
|
12657
|
+
var TRACE_ROOT = "/__tamperward_trusted_base__";
|
|
12658
|
+
function unescapeStraceString(s) {
|
|
12659
|
+
return s.replace(/\\([0-7]{1,3})/g, (_m, oct) => String.fromCharCode(parseInt(oct, 8))).replace(/\\x([0-9a-fA-F]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16))).replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
12660
|
+
}
|
|
12661
|
+
function parseStraceFileAccess(raw) {
|
|
12662
|
+
const out2 = [];
|
|
12663
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12664
|
+
for (const line of raw.split("\n")) {
|
|
12665
|
+
if (!line.trim() || /\)\s+=\s+-1\b/.test(line)) continue;
|
|
12666
|
+
const call = line.match(/\b(execveat|execve|openat2|openat|open|newfstatat|fstatat|statx|lstat|stat|access|readlinkat|readlink)\s*\(/);
|
|
12667
|
+
if (!call) continue;
|
|
12668
|
+
const afterCall = line.slice((call.index ?? 0) + call[0].length);
|
|
12669
|
+
const quoted = afterCall.match(/"((?:\\.|[^"])*)"/);
|
|
12670
|
+
if (!quoted) continue;
|
|
12671
|
+
const path = unescapeStraceString(quoted[1]);
|
|
12672
|
+
if (!path) continue;
|
|
12673
|
+
const access = call[1].startsWith("execve") ? "exec" : "read";
|
|
12674
|
+
const key2 = `${access}\0${path}`;
|
|
12675
|
+
if (seen.has(key2)) continue;
|
|
12676
|
+
seen.add(key2);
|
|
12677
|
+
out2.push({ path, access });
|
|
12678
|
+
}
|
|
12679
|
+
return out2;
|
|
12680
|
+
}
|
|
12681
|
+
function inside3(root, path) {
|
|
12682
|
+
const rel = relative6(resolve9(root), resolve9(path));
|
|
12683
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute11(rel);
|
|
12684
|
+
}
|
|
12685
|
+
function normalRepoPath(root, raw) {
|
|
12686
|
+
const abs2 = isAbsolute11(raw) ? resolve9(raw) : resolve9(root, raw);
|
|
12687
|
+
if (!inside3(root, abs2)) return null;
|
|
12688
|
+
const rel = relative6(root, abs2).split(sep3).join("/");
|
|
12689
|
+
return rel && rel !== ".." ? rel : null;
|
|
12690
|
+
}
|
|
12691
|
+
var CONFIG_BASENAMES = /* @__PURE__ */ new Set([
|
|
12692
|
+
"package.json",
|
|
12693
|
+
"pytest.ini",
|
|
12694
|
+
".pytest.ini",
|
|
12695
|
+
"setup.cfg",
|
|
12696
|
+
"setup.py",
|
|
12697
|
+
"tox.ini",
|
|
12698
|
+
"pyproject.toml",
|
|
12699
|
+
".npmrc",
|
|
12700
|
+
".yarnrc",
|
|
12701
|
+
".yarnrc.yml",
|
|
12702
|
+
"pnpm-workspace.yaml",
|
|
12703
|
+
".pnpmfile.cjs",
|
|
12704
|
+
".pnp.cjs",
|
|
12705
|
+
".pnp.js",
|
|
12706
|
+
".pnp.loader.mjs",
|
|
12707
|
+
".nvmrc",
|
|
12708
|
+
".node-version",
|
|
12709
|
+
".python-version",
|
|
12710
|
+
".gitattributes",
|
|
12711
|
+
".rspec",
|
|
12712
|
+
"phpunit.xml",
|
|
12713
|
+
"phpunit.xml.dist"
|
|
12714
|
+
]);
|
|
12715
|
+
function likelyConfig(path) {
|
|
12716
|
+
const base = path.split("/").at(-1) ?? path;
|
|
12717
|
+
return CONFIG_BASENAMES.has(base) || /(?:^|\/)(?:config|configs)\//.test(path) || /^\.env(?:\.|$)/.test(base) || /^(?:jest|vitest|playwright|cypress|vite|babel|karma|ava)\.config\./.test(base) || /^tsconfig(?:\..+)?\.json$/.test(base) || /^\.?(?:babelrc|swcrc|nycrc|c8rc)/.test(base) || /(?:^|\/)conftest\.py$/.test(path);
|
|
12718
|
+
}
|
|
12719
|
+
function sortAccesses(values) {
|
|
12720
|
+
return [...values].sort((a, b) => a.localeCompare(b));
|
|
12721
|
+
}
|
|
12722
|
+
function summarizeTraceRuns(opts) {
|
|
12723
|
+
const repo = /* @__PURE__ */ new Map();
|
|
12724
|
+
const external = /* @__PURE__ */ new Map();
|
|
12725
|
+
opts.runs.forEach((run, index) => {
|
|
12726
|
+
const perRun = /* @__PURE__ */ new Set();
|
|
12727
|
+
for (const item of run) {
|
|
12728
|
+
const rel = normalRepoPath(opts.root, item.path);
|
|
12729
|
+
const isTracked = rel !== null && opts.tracked.has(rel);
|
|
12730
|
+
const keyPath = isTracked ? rel : rel === null ? resolve9(opts.root, item.path) : null;
|
|
12731
|
+
if (keyPath === null) continue;
|
|
12732
|
+
const target = isTracked ? repo : external;
|
|
12733
|
+
const key2 = `${isTracked ? "repo" : "external"}\0${keyPath}`;
|
|
12734
|
+
let acc2 = target.get(keyPath);
|
|
12735
|
+
if (!acc2) {
|
|
12736
|
+
acc2 = { accesses: /* @__PURE__ */ new Set(), runs: /* @__PURE__ */ new Set() };
|
|
12737
|
+
target.set(keyPath, acc2);
|
|
12738
|
+
}
|
|
12739
|
+
acc2.accesses.add(item.access);
|
|
12740
|
+
if (!perRun.has(key2)) {
|
|
12741
|
+
acc2.runs.add(index);
|
|
12742
|
+
perRun.add(key2);
|
|
12743
|
+
}
|
|
12744
|
+
}
|
|
12745
|
+
});
|
|
12746
|
+
const totalRuns = opts.runs.length;
|
|
12747
|
+
const repository_inputs = [...repo.entries()].map(([path, acc2]) => ({
|
|
12748
|
+
path,
|
|
12749
|
+
accesses: sortAccesses(acc2.accesses),
|
|
12750
|
+
config: likelyConfig(path),
|
|
12751
|
+
covered: opts.covered.has(path),
|
|
12752
|
+
observed_runs: acc2.runs.size,
|
|
12753
|
+
dynamic: acc2.runs.size < totalRuns
|
|
12754
|
+
})).sort((a, b) => a.path.localeCompare(b.path));
|
|
12755
|
+
const external_inputs = [...external.entries()].map(([path, acc2]) => ({
|
|
12756
|
+
path,
|
|
12757
|
+
accesses: sortAccesses(acc2.accesses),
|
|
12758
|
+
observed_runs: acc2.runs.size,
|
|
12759
|
+
dynamic: acc2.runs.size < totalRuns
|
|
12760
|
+
})).sort((a, b) => a.path.localeCompare(b.path));
|
|
12761
|
+
const uncovered_repository_inputs = repository_inputs.filter((x) => !x.covered).map((x) => x.path);
|
|
12762
|
+
return {
|
|
12763
|
+
repository_inputs,
|
|
12764
|
+
external_inputs,
|
|
12765
|
+
uncovered_repository_inputs,
|
|
12766
|
+
// Exact paths are intentionally conservative. A maintainer may widen them to
|
|
12767
|
+
// reviewed globs, but TamperWard never invents a broader trust surface.
|
|
12768
|
+
suggested_verify_inputs: [...uncovered_repository_inputs]
|
|
12769
|
+
};
|
|
12770
|
+
}
|
|
12771
|
+
function git4(args, cwd) {
|
|
12772
|
+
return execFileSync11("git", args, {
|
|
12773
|
+
cwd,
|
|
12774
|
+
encoding: "utf8",
|
|
12775
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
12776
|
+
env: trustedGitEnv(),
|
|
12777
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
12778
|
+
});
|
|
12779
|
+
}
|
|
12780
|
+
function commandExists(command) {
|
|
12781
|
+
const r = spawnSync5(command, ["--version"], { stdio: "ignore" });
|
|
12782
|
+
return !r.error && r.status === 0;
|
|
12783
|
+
}
|
|
12784
|
+
function trackedAt(base, cwd) {
|
|
12785
|
+
return git4(["ls-tree", "-r", "-z", "--name-only", base], cwd).split("\0").filter(Boolean);
|
|
12786
|
+
}
|
|
12787
|
+
function materializeBase(base, cwd, dest) {
|
|
12788
|
+
const archive = spawnSync5("git", ["archive", "--format=tar", base], {
|
|
12789
|
+
cwd,
|
|
12790
|
+
env: trustedGitEnv(),
|
|
12791
|
+
maxBuffer: 256 * 1024 * 1024
|
|
12792
|
+
});
|
|
12793
|
+
if (archive.error || archive.status !== 0 || !archive.stdout) {
|
|
12794
|
+
throw new Error(
|
|
12795
|
+
`git archive failed${archive.stderr?.length ? `: ${String(archive.stderr).trim()}` : ""}`
|
|
12796
|
+
);
|
|
12797
|
+
}
|
|
12798
|
+
const untar = spawnSync5("tar", ["-xf", "-", "-C", dest], {
|
|
12799
|
+
input: archive.stdout,
|
|
12800
|
+
maxBuffer: 256 * 1024 * 1024
|
|
12801
|
+
});
|
|
12802
|
+
if (untar.error || untar.status !== 0) {
|
|
12803
|
+
throw new Error(
|
|
12804
|
+
`tar extraction failed${untar.stderr?.length ? `: ${String(untar.stderr).trim()}` : ""}`
|
|
12805
|
+
);
|
|
12806
|
+
}
|
|
12807
|
+
const hostModules = join19(cwd, "node_modules");
|
|
12808
|
+
const tracedModules = join19(dest, "node_modules");
|
|
12809
|
+
if (existsSync13(hostModules) && !existsSync13(tracedModules)) {
|
|
12810
|
+
symlinkSync2(resolve9(hostModules), tracedModules, "dir");
|
|
12811
|
+
}
|
|
12812
|
+
}
|
|
12813
|
+
function traceFiles(prefix) {
|
|
12814
|
+
const dir = resolve9(prefix, "..");
|
|
12815
|
+
const base = prefix.split(sep3).at(-1);
|
|
12816
|
+
return readdirSync6(dir).filter((name) => name === base || name.startsWith(base + ".")).map((name) => join19(dir, name)).sort();
|
|
12817
|
+
}
|
|
12818
|
+
function rewriteTraceRoot(accesses, actualRoot) {
|
|
12819
|
+
return accesses.map((item) => {
|
|
12820
|
+
const abs2 = isAbsolute11(item.path) ? resolve9(item.path) : resolve9(actualRoot, item.path);
|
|
12821
|
+
if (!inside3(actualRoot, abs2)) return { ...item, path: abs2 };
|
|
12822
|
+
const rel = relative6(actualRoot, abs2).split(sep3).join("/");
|
|
12823
|
+
try {
|
|
12824
|
+
const real = resolve9(realpathSync7(abs2));
|
|
12825
|
+
if (!inside3(actualRoot, real)) return { ...item, path: real };
|
|
12826
|
+
} catch {
|
|
12827
|
+
}
|
|
12828
|
+
return { ...item, path: join19(TRACE_ROOT, ...rel.split("/")) };
|
|
12829
|
+
});
|
|
12830
|
+
}
|
|
12831
|
+
function runOneTrace(cwd, base, command, budget) {
|
|
12832
|
+
const root = mkdtempSync5(join19(tmpdir5(), "tw-trace-base-"));
|
|
12833
|
+
const traceDir = mkdtempSync5(join19(tmpdir5(), "tw-trace-log-"));
|
|
12834
|
+
try {
|
|
12835
|
+
materializeBase(base, cwd, root);
|
|
12836
|
+
const prefix = join19(traceDir, "trace");
|
|
12837
|
+
const traced = spawnSync5(
|
|
12838
|
+
"strace",
|
|
12839
|
+
[
|
|
12840
|
+
"-ff",
|
|
12841
|
+
"-qq",
|
|
12842
|
+
"-s",
|
|
12843
|
+
"4096",
|
|
12844
|
+
"-e",
|
|
12845
|
+
"trace=%file",
|
|
12846
|
+
"-o",
|
|
12847
|
+
prefix,
|
|
12848
|
+
"--",
|
|
12849
|
+
"timeout",
|
|
12850
|
+
"--signal=TERM",
|
|
12851
|
+
"--kill-after=2s",
|
|
12852
|
+
`${budget}s`,
|
|
12853
|
+
"sh",
|
|
12854
|
+
"-c",
|
|
12855
|
+
command
|
|
12856
|
+
],
|
|
12857
|
+
{
|
|
12858
|
+
cwd: root,
|
|
12859
|
+
env: process.env,
|
|
12860
|
+
encoding: "utf8",
|
|
12861
|
+
maxBuffer: 64 * 1024 * 1024
|
|
12862
|
+
}
|
|
12863
|
+
);
|
|
12864
|
+
if (traced.error) throw traced.error;
|
|
12865
|
+
const logs = traceFiles(prefix);
|
|
12866
|
+
if (logs.length === 0) {
|
|
12867
|
+
throw new Error("strace produced no trace files");
|
|
12868
|
+
}
|
|
12869
|
+
const accesses = logs.flatMap((path) => parseStraceFileAccess(readFileSync18(path, "utf8")));
|
|
12870
|
+
return {
|
|
12871
|
+
exit: traced.status ?? 1,
|
|
12872
|
+
accesses: rewriteTraceRoot(accesses, root)
|
|
12873
|
+
};
|
|
12874
|
+
} finally {
|
|
12875
|
+
rmSync6(root, { recursive: true, force: true });
|
|
12876
|
+
rmSync6(traceDir, { recursive: true, force: true });
|
|
12877
|
+
}
|
|
12878
|
+
}
|
|
12879
|
+
function renderText2(report2) {
|
|
12880
|
+
const out2 = (s = "") => void process.stdout.write(s + "\n");
|
|
12881
|
+
out2("tamperward trace-verify \u2014 ADVISORY observation, not an enforcement verdict");
|
|
12882
|
+
out2(`trusted base: ${report2.base}`);
|
|
12883
|
+
out2(`command: ${report2.command}`);
|
|
12884
|
+
out2(`runs: ${report2.runs_completed}/${report2.runs_requested}; exits: ${report2.run_exits.join(", ")}`);
|
|
12885
|
+
out2();
|
|
12886
|
+
out2("repository inputs observed:");
|
|
12887
|
+
if (!report2.repository_inputs.length) out2(" (none)");
|
|
12888
|
+
for (const item of report2.repository_inputs) {
|
|
12889
|
+
const flags = [
|
|
12890
|
+
item.covered ? "covered" : "UNCOVERED",
|
|
12891
|
+
item.config ? "config" : null,
|
|
12892
|
+
item.dynamic ? `dynamic ${item.observed_runs}/${report2.runs_completed}` : null,
|
|
12893
|
+
item.accesses.join("+")
|
|
12894
|
+
].filter(Boolean).join(", ");
|
|
12895
|
+
out2(` ${item.path} [${flags}]`);
|
|
12896
|
+
}
|
|
12897
|
+
out2();
|
|
12898
|
+
out2("external dependency/runtime paths observed:");
|
|
12899
|
+
if (!report2.external_inputs.length) out2(" (none)");
|
|
12900
|
+
for (const item of report2.external_inputs) {
|
|
12901
|
+
out2(
|
|
12902
|
+
` ${item.path} [${item.accesses.join("+")}${item.dynamic ? `, dynamic ${item.observed_runs}/${report2.runs_completed}` : ""}]`
|
|
12903
|
+
);
|
|
12904
|
+
}
|
|
12905
|
+
out2();
|
|
12906
|
+
out2("candidate verify.inputs entries for HUMAN REVIEW:");
|
|
12907
|
+
if (!report2.suggested_verify_inputs.length) out2(" (none \u2014 all observed tracked inputs are already covered)");
|
|
12908
|
+
for (const path of report2.suggested_verify_inputs) out2(` - ${JSON.stringify(path)}`);
|
|
12909
|
+
out2();
|
|
12910
|
+
for (const note of report2.notes) out2(`note: ${note}`);
|
|
12911
|
+
}
|
|
12912
|
+
function runTraceVerify(opts = {}) {
|
|
12913
|
+
const cwd = resolve9(opts.cwd ?? process.cwd());
|
|
12914
|
+
if (process.platform !== "linux") {
|
|
12915
|
+
process.stderr.write("tamperward trace-verify: Linux only in this release; unsupported platform \u2014 no parity is implied.\n");
|
|
12916
|
+
return 2;
|
|
12917
|
+
}
|
|
12918
|
+
if (!commandExists("strace")) {
|
|
12919
|
+
process.stderr.write("tamperward trace-verify: strace is required on Linux but was not found.\n");
|
|
12920
|
+
return 2;
|
|
12921
|
+
}
|
|
12922
|
+
if (!commandExists("tar")) {
|
|
12923
|
+
process.stderr.write("tamperward trace-verify: tar is required to materialize the trusted base but was not found.\n");
|
|
12924
|
+
return 2;
|
|
12925
|
+
}
|
|
12926
|
+
if (!commandExists("timeout")) {
|
|
12927
|
+
process.stderr.write("tamperward trace-verify: GNU timeout is required to bound traced verifier runs but was not found.\n");
|
|
12928
|
+
return 2;
|
|
12929
|
+
}
|
|
12930
|
+
const runs = opts.runs ?? 2;
|
|
12931
|
+
if (!Number.isInteger(runs) || runs <= 0) {
|
|
12932
|
+
process.stderr.write(`tamperward trace-verify: --runs needs a positive integer (got ${JSON.stringify(opts.runs)}).
|
|
12933
|
+
`);
|
|
12934
|
+
return 2;
|
|
12935
|
+
}
|
|
12936
|
+
const requestedBase = opts.base ?? "HEAD";
|
|
12937
|
+
let base;
|
|
12938
|
+
try {
|
|
12939
|
+
const baseArg = assertRev(requestedBase);
|
|
12940
|
+
base = git4(["rev-parse", "--verify", `${baseArg}^{commit}`], cwd).trim();
|
|
12941
|
+
} catch (e) {
|
|
12942
|
+
process.stderr.write(`tamperward trace-verify: cannot resolve trusted base ${JSON.stringify(requestedBase)} (${e instanceof Error ? e.message : String(e)}).
|
|
12943
|
+
`);
|
|
12944
|
+
return 2;
|
|
12945
|
+
}
|
|
12946
|
+
let policy;
|
|
12947
|
+
try {
|
|
12948
|
+
policy = loadPolicyAt(base, cwd) ?? defaultPolicy();
|
|
12949
|
+
} catch (e) {
|
|
12950
|
+
process.stderr.write(`tamperward trace-verify: cannot load policy at trusted base (${e instanceof Error ? e.message : String(e)}).
|
|
12951
|
+
`);
|
|
12952
|
+
return 2;
|
|
12953
|
+
}
|
|
12954
|
+
const command = opts.cmd ?? policy.verify?.command;
|
|
12955
|
+
if (!command) {
|
|
12956
|
+
process.stderr.write("tamperward trace-verify: no verifier command; set verify.command at the base or pass --cmd.\n");
|
|
12957
|
+
return 2;
|
|
12958
|
+
}
|
|
12959
|
+
const budget = opts.budget ?? policy.verify?.budget ?? 300;
|
|
12960
|
+
if (!Number.isFinite(budget) || budget <= 0) {
|
|
12961
|
+
process.stderr.write(`tamperward trace-verify: --budget needs a positive number of seconds (got ${JSON.stringify(budget)}).
|
|
12962
|
+
`);
|
|
12963
|
+
return 2;
|
|
12964
|
+
}
|
|
12965
|
+
const atBase = trackedAt(base, cwd);
|
|
12966
|
+
const tracked = new Set(atBase);
|
|
12967
|
+
const covered = verifierCoveredInputs(command, atBase, policy);
|
|
12968
|
+
const observed = [];
|
|
12969
|
+
const runExits = [];
|
|
12970
|
+
try {
|
|
12971
|
+
for (let i = 0; i < runs; i++) {
|
|
12972
|
+
const result = runOneTrace(cwd, base, command, budget);
|
|
12973
|
+
observed.push(result.accesses);
|
|
12974
|
+
runExits.push(result.exit);
|
|
12975
|
+
}
|
|
12976
|
+
} catch (e) {
|
|
12977
|
+
process.stderr.write(`tamperward trace-verify: tracing failed (${e instanceof Error ? e.message : String(e)}).
|
|
12978
|
+
`);
|
|
12979
|
+
return 2;
|
|
12980
|
+
}
|
|
12981
|
+
const summary = summarizeTraceRuns({
|
|
12982
|
+
root: TRACE_ROOT,
|
|
12983
|
+
runs: observed,
|
|
12984
|
+
tracked,
|
|
12985
|
+
covered
|
|
12986
|
+
});
|
|
12987
|
+
const report2 = {
|
|
12988
|
+
advisory: true,
|
|
12989
|
+
platform: process.platform,
|
|
12990
|
+
base,
|
|
12991
|
+
command,
|
|
12992
|
+
runs_requested: runs,
|
|
12993
|
+
runs_completed: observed.length,
|
|
12994
|
+
run_exits: runExits,
|
|
12995
|
+
trace_complete: runExits.every((code2) => code2 === 0),
|
|
12996
|
+
...summary,
|
|
12997
|
+
notes: [
|
|
12998
|
+
"Observed reads are evidence from these runs, not proof that an unobserved path can never be read.",
|
|
12999
|
+
"Dynamic paths are those observed in fewer than all repeated traces.",
|
|
13000
|
+
"Suggestions are exact tracked paths only; TamperWard never edits .tamperward.yml or widens a glob automatically.",
|
|
13001
|
+
"Review every suggestion before adding it to verify.inputs."
|
|
13002
|
+
]
|
|
13003
|
+
};
|
|
13004
|
+
if (opts.json) process.stdout.write(JSON.stringify(report2) + "\n");
|
|
13005
|
+
else renderText2(report2);
|
|
13006
|
+
return report2.trace_complete ? 0 : 1;
|
|
13007
|
+
}
|
|
13008
|
+
function parseTraceVerify(args) {
|
|
13009
|
+
const out2 = {};
|
|
13010
|
+
for (let i = 0; i < args.length; i++) {
|
|
13011
|
+
const a = args[i];
|
|
13012
|
+
if (a === "--json") out2.json = true;
|
|
13013
|
+
else if (a === "--base") out2.base = args[++i];
|
|
13014
|
+
else if (a === "--cmd") out2.cmd = args[++i];
|
|
13015
|
+
else if (a === "--cwd") out2.cwd = args[++i];
|
|
13016
|
+
else if (a === "--budget") out2.budget = Number(args[++i]);
|
|
13017
|
+
else if (a === "--runs") out2.runs = Number(args[++i]);
|
|
13018
|
+
}
|
|
13019
|
+
return out2;
|
|
13020
|
+
}
|
|
13021
|
+
|
|
12630
13022
|
// src/cli/main.ts
|
|
12631
13023
|
function parseAllow(args) {
|
|
12632
13024
|
const o = {};
|
|
@@ -12715,6 +13107,11 @@ function validateFlatArgs(args, grammar) {
|
|
|
12715
13107
|
if (!Number.isFinite(n) || n <= 0) {
|
|
12716
13108
|
return { error: `${a} needs a positive number (got "${v}")`, seen, positionals };
|
|
12717
13109
|
}
|
|
13110
|
+
} else if (rule === "positive-integer") {
|
|
13111
|
+
const n = Number(v);
|
|
13112
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
|
13113
|
+
return { error: `${a} needs a positive integer (got "${v}")`, seen, positionals };
|
|
13114
|
+
}
|
|
12718
13115
|
} else if (rule === "non-negative") {
|
|
12719
13116
|
const n = Number(v);
|
|
12720
13117
|
if (!Number.isFinite(n) || n < 0) {
|
|
@@ -12817,6 +13214,18 @@ function validateCliArgs(cmd, args) {
|
|
|
12817
13214
|
}
|
|
12818
13215
|
}).error;
|
|
12819
13216
|
}
|
|
13217
|
+
if (cmd === "trace-verify") {
|
|
13218
|
+
return validateFlatArgs(args, {
|
|
13219
|
+
flags: ["--json"],
|
|
13220
|
+
values: {
|
|
13221
|
+
"--base": "string",
|
|
13222
|
+
"--cmd": "string",
|
|
13223
|
+
"--cwd": "string",
|
|
13224
|
+
"--budget": "positive",
|
|
13225
|
+
"--runs": "positive-integer"
|
|
13226
|
+
}
|
|
13227
|
+
}).error;
|
|
13228
|
+
}
|
|
12820
13229
|
if (cmd === "watch") {
|
|
12821
13230
|
return validateFlatArgs(args, {
|
|
12822
13231
|
values: { "--dir": "string", "--log": "string", "--base": "string" }
|
|
@@ -12857,6 +13266,14 @@ Formats:
|
|
|
12857
13266
|
(exit 1, or 0 under an out-of-band
|
|
12858
13267
|
verify@<head-sha> approval); cannot-verify
|
|
12859
13268
|
fails closed (2)
|
|
13269
|
+
tamperward trace-verify [--base R] advisory Linux verifier-input discovery:
|
|
13270
|
+
[--cmd C] [--budget S] [--runs N] trace a trusted/known-good base with
|
|
13271
|
+
[--json] [--cwd D] strace, union repeated observations,
|
|
13272
|
+
mark dynamic/config/external reads,
|
|
13273
|
+
and suggest uncovered exact paths for
|
|
13274
|
+
HUMAN review as verify.inputs. Never
|
|
13275
|
+
edits policy; unsupported platforms
|
|
13276
|
+
fail explicitly rather than claiming parity.
|
|
12860
13277
|
tamperward run [opts] -- <agent cmd...> enforcement envelope: record the trusted
|
|
12861
13278
|
[--base R] [--cmd C] base, run the agent, treat its exit as
|
|
12862
13279
|
[--budget S] [--agent-budget S] untrusted, then re-adjudicate the tree it
|
|
@@ -12930,6 +13347,8 @@ function main(argv2) {
|
|
|
12930
13347
|
return runWatch(rest);
|
|
12931
13348
|
case "verify":
|
|
12932
13349
|
return runVerify(parseVerify(rest));
|
|
13350
|
+
case "trace-verify":
|
|
13351
|
+
return runTraceVerify(parseTraceVerify(rest));
|
|
12933
13352
|
case "run":
|
|
12934
13353
|
return runEnvelope({
|
|
12935
13354
|
...parseRun(rest),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.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",
|