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 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, resolve9) {
2014
- const obj = resolve9(expr);
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(resolve9(p.initializer));
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 resolve9 = resolverFor(sf);
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 = resolve9(node.initializer);
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, resolve9);
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, resolve9);
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 resolve9 = resolverFor(sf);
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(resolve9(node.initializer));
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(resolve9(node.initializer));
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 isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c)) || matchesAny(p, VERIFICATION_SURFACE) || verifierOwned.has(p) || verifierGlobs.length > 0 && matchesAny(p, verifierGlobs);
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.17.1",
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",