tamperward 2.11.1 → 2.11.3
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 +29 -13
- package/dist/cli/index.js +321 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,7 +155,15 @@ After the runtime exits its exit code is treated as untrusted, and the envelope
|
|
|
155
155
|
checks that post-agent `HEAD` still descends from the entry commit; the committed
|
|
156
156
|
changes over `entry...HEAD`; staged, unstaged and untracked non-ignored worktree
|
|
157
157
|
changes; dependency drift and whether the tree stayed quiescent; and the
|
|
158
|
-
verification outcome.
|
|
158
|
+
verification outcome. For agent runtimes that may hang, `--agent-budget <seconds>`
|
|
159
|
+
adds an operator-owned wall-clock boundary around the **agent itself** (separate from
|
|
160
|
+
the verifier's `--budget`). On expiry TamperWard terminates the owned process tree,
|
|
161
|
+
then still performs the same post-timeout adjudication. A clean timeout is
|
|
162
|
+
`AGENT_TIMEOUT` / exit 124; any enforcement failure or cannot-adjudicate result
|
|
163
|
+
still outranks that runtime status. On POSIX the agent starts in its own process
|
|
164
|
+
group; on Linux the timeout also enumerates the agent's `/proc` descendant tree so
|
|
165
|
+
a child that escaped the group with `setsid()` is terminated before adjudication.
|
|
166
|
+
Windows uses `taskkill /T /F` as the explicit process-tree fallback.
|
|
159
167
|
|
|
160
168
|
`tamperward verify` materialises two temporary copies — they are **not
|
|
161
169
|
sandboxes**, and both execute agent-controlled code. The visible copy runs the
|
|
@@ -175,6 +183,7 @@ semantic correctness nor a substitute for independent held-out tests.
|
|
|
175
183
|
| Enforcement clean and agent exits 0 | exit 0 |
|
|
176
184
|
| Blocking finding, masked failure, dependency drift, or non-quiescent tree | exit 1 |
|
|
177
185
|
| Required adjudication cannot be completed | exit 2, failing closed |
|
|
186
|
+
| Enforcement clean but the agent exceeded `--agent-budget` | `AGENT_TIMEOUT`, exit 124 |
|
|
178
187
|
| Enforcement clean but the agent failed | the agent's non-zero exit is preserved |
|
|
179
188
|
|
|
180
189
|
### CI authority
|
|
@@ -262,7 +271,14 @@ verify:
|
|
|
262
271
|
local Docker daemon; Tamperward never pulls during adjudication. The materialised
|
|
263
272
|
candidate/pristine tree is mounted read-only, the image owns runtime/dependencies,
|
|
264
273
|
network is disabled, HOME/tmp are private, and optional suite output belongs in
|
|
265
|
-
`$TAMPERWARD_OUTPUT_DIR` (`/workspace-out`).
|
|
274
|
+
`$TAMPERWARD_OUTPUT_DIR` (`/workspace-out`). The isolated verifier also applies a
|
|
275
|
+
fixed host-protection envelope: **2 GiB memory, no additional swap, 2 CPUs and 256
|
|
276
|
+
PIDs**, in addition to the configured wall-clock budget. Those limits are included in
|
|
277
|
+
the JSON backend report. Docker-confirmed memory OOM is `CANNOT_VERIFY` with
|
|
278
|
+
`VERIFIER_RESOURCE_EXHAUSTED`, never a suite failure; an exit such as 137 without
|
|
279
|
+
`OOMKilled=true` remains the suite's own exit. v2.11.2 deliberately has no environment
|
|
280
|
+
variable tuning knob for these ceilings because candidate-controlled CI/env must not
|
|
281
|
+
weaken verifier containment. Images that declare Dockerfile
|
|
266
282
|
`VOLUME` paths are refused: Docker mounts those paths writable even with
|
|
267
283
|
`--read-only`, which would undermine the immutable verifier-image boundary. Image
|
|
268
284
|
`ENTRYPOINT` is also overridden; the pinned image supplies the runtime/dependencies,
|
|
@@ -287,7 +303,7 @@ The four primitives:
|
|
|
287
303
|
npx tamperward check --staged # pre-commit view
|
|
288
304
|
npx tamperward check --diff "main...HEAD" # CI view over the PR's commit range
|
|
289
305
|
npx tamperward verify --base main # pristine-suite re-execution
|
|
290
|
-
npx tamperward run -- <agent command...>
|
|
306
|
+
npx tamperward run --agent-budget 1800 -- <agent command...> # optional agent-runtime bound
|
|
291
307
|
```
|
|
292
308
|
|
|
293
309
|
The isolated backend is a **frozen-artifact final verifier**. `tamperward run`
|
|
@@ -305,7 +321,7 @@ commands ignore what they do not know.
|
|
|
305
321
|
| --- | --- |
|
|
306
322
|
| `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
|
|
307
323
|
| `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>` |
|
|
308
|
-
| `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...>` |
|
|
324
|
+
| `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` (per verifier suite) · `--agent-budget <seconds>` (optional wrapped-agent wall clock) · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
|
|
309
325
|
| `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
|
|
310
326
|
| `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
|
|
311
327
|
| `watch` | `--dir <dir>` · `--log <file>` — a daemon; it runs until signalled |
|
|
@@ -313,15 +329,15 @@ commands ignore what they do not know.
|
|
|
313
329
|
|
|
314
330
|
**Exit codes** — part of the public surface:
|
|
315
331
|
|
|
316
|
-
| command | 0 | 1 | 2 |
|
|
317
|
-
| --- | --- | --- | --- |
|
|
318
|
-
| `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 |
|
|
319
|
-
| `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 |
|
|
320
|
-
| `run` | enforcement clean and the agent exited 0 —
|
|
321
|
-
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
|
|
322
|
-
| `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off |
|
|
323
|
-
| `init` | wired, or already wired | — | an item needs attention |
|
|
324
|
-
| no or unknown command | help printed (no command) | — | unknown command, help printed |
|
|
332
|
+
| command | 0 | 1 | 2 | 124 |
|
|
333
|
+
| --- | --- | --- | --- | --- |
|
|
334
|
+
| `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 | — |
|
|
335
|
+
| `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 | — |
|
|
336
|
+
| `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 |
|
|
337
|
+
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name | — |
|
|
338
|
+
| `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off | — |
|
|
339
|
+
| `init` | wired, or already wired | — | an item needs attention | — |
|
|
340
|
+
| no or unknown command | help printed (no command) | — | unknown command, help printed | — |
|
|
325
341
|
|
|
326
342
|
### Environment variables
|
|
327
343
|
|
package/dist/cli/index.js
CHANGED
|
@@ -8581,6 +8581,13 @@ import { createHash as createHash9, randomUUID } from "node:crypto";
|
|
|
8581
8581
|
import { spawnSync } from "node:child_process";
|
|
8582
8582
|
import { accessSync, constants as constants2, readFileSync as readFileSync14, realpathSync as realpathSync4, statSync as statSync5 } from "node:fs";
|
|
8583
8583
|
import { delimiter as delimiter2, dirname as dirname6, join as join14, resolve as resolve5 } from "node:path";
|
|
8584
|
+
var DEFAULT_CONTAINER_RESOURCE_LIMITS = Object.freeze({
|
|
8585
|
+
memoryBytes: 2 * 1024 * 1024 * 1024,
|
|
8586
|
+
// Docker interprets memory-swap == memory as no additional swap allowance.
|
|
8587
|
+
memorySwapBytes: 2 * 1024 * 1024 * 1024,
|
|
8588
|
+
cpus: 2,
|
|
8589
|
+
pids: 256
|
|
8590
|
+
});
|
|
8584
8591
|
var DIGEST_IMAGE = /^(?!-)[^\s@]+@sha256:[0-9a-f]{64}$/i;
|
|
8585
8592
|
var DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock";
|
|
8586
8593
|
function identity() {
|
|
@@ -8808,7 +8815,8 @@ function prepareVerifierBackend(verify) {
|
|
|
8808
8815
|
engine: "docker",
|
|
8809
8816
|
enginePath,
|
|
8810
8817
|
engineSha256: fileSha256(enginePath),
|
|
8811
|
-
daemonHost
|
|
8818
|
+
daemonHost,
|
|
8819
|
+
resources: { ...DEFAULT_CONTAINER_RESOURCE_LIMITS }
|
|
8812
8820
|
};
|
|
8813
8821
|
}
|
|
8814
8822
|
function verifierBackendReport(backend) {
|
|
@@ -8818,6 +8826,14 @@ function verifierBackendReport(backend) {
|
|
|
8818
8826
|
available: backend.available,
|
|
8819
8827
|
...backend.image ? { image: backend.image } : {},
|
|
8820
8828
|
...backend.engine ? { engine: backend.engine } : {},
|
|
8829
|
+
...backend.kind === "container" ? {
|
|
8830
|
+
resources: {
|
|
8831
|
+
memory_bytes: (backend.resources ?? DEFAULT_CONTAINER_RESOURCE_LIMITS).memoryBytes,
|
|
8832
|
+
memory_swap_bytes: (backend.resources ?? DEFAULT_CONTAINER_RESOURCE_LIMITS).memorySwapBytes,
|
|
8833
|
+
cpus: (backend.resources ?? DEFAULT_CONTAINER_RESOURCE_LIMITS).cpus,
|
|
8834
|
+
pids: (backend.resources ?? DEFAULT_CONTAINER_RESOURCE_LIMITS).pids
|
|
8835
|
+
}
|
|
8836
|
+
} : {},
|
|
8821
8837
|
...backend.reason ? { reason: backend.reason } : {}
|
|
8822
8838
|
};
|
|
8823
8839
|
}
|
|
@@ -8829,7 +8845,8 @@ function verifierBackendSummary(backend) {
|
|
|
8829
8845
|
if (!backend.available) {
|
|
8830
8846
|
return `container (isolated; unavailable: ${backend.reason ?? "unknown reason"}; image ${identity2})`;
|
|
8831
8847
|
}
|
|
8832
|
-
|
|
8848
|
+
const resources = backend.resources ?? DEFAULT_CONTAINER_RESOURCE_LIMITS;
|
|
8849
|
+
return `container (isolated via Docker; image ${identity2}; ${Math.round(resources.memoryBytes / (1024 * 1024))} MiB memory, no extra swap, ${resources.cpus} CPUs, ${resources.pids} PIDs)`;
|
|
8833
8850
|
}
|
|
8834
8851
|
function cleanupContainer(backend, name) {
|
|
8835
8852
|
if (!backend.enginePath || !backend.daemonHost || !engineStable(backend)) return;
|
|
@@ -8844,6 +8861,7 @@ function cleanupContainer(backend, name) {
|
|
|
8844
8861
|
}
|
|
8845
8862
|
}
|
|
8846
8863
|
function containerRunArgs(input) {
|
|
8864
|
+
const resources = input.resources ?? DEFAULT_CONTAINER_RESOURCE_LIMITS;
|
|
8847
8865
|
return [
|
|
8848
8866
|
"run",
|
|
8849
8867
|
"--name",
|
|
@@ -8860,8 +8878,14 @@ function containerRunArgs(input) {
|
|
|
8860
8878
|
"ALL",
|
|
8861
8879
|
"--security-opt",
|
|
8862
8880
|
"no-new-privileges",
|
|
8881
|
+
"--memory",
|
|
8882
|
+
String(resources.memoryBytes),
|
|
8883
|
+
"--memory-swap",
|
|
8884
|
+
String(resources.memorySwapBytes),
|
|
8885
|
+
"--cpus",
|
|
8886
|
+
String(resources.cpus),
|
|
8863
8887
|
"--pids-limit",
|
|
8864
|
-
|
|
8888
|
+
String(resources.pids),
|
|
8865
8889
|
"--user",
|
|
8866
8890
|
`${input.uid}:${input.gid}`,
|
|
8867
8891
|
// Frozen candidate/pristine input is immutable in the verifier. A suite
|
|
@@ -8895,6 +8919,26 @@ function containerRunArgs(input) {
|
|
|
8895
8919
|
input.command
|
|
8896
8920
|
];
|
|
8897
8921
|
}
|
|
8922
|
+
function containerStateResult(state, secs) {
|
|
8923
|
+
if (state?.OOMKilled) {
|
|
8924
|
+
return {
|
|
8925
|
+
exit: null,
|
|
8926
|
+
secs,
|
|
8927
|
+
failure: "resource",
|
|
8928
|
+
resource: "memory",
|
|
8929
|
+
reason: "verifier container exceeded its memory limit and was OOM-killed"
|
|
8930
|
+
};
|
|
8931
|
+
}
|
|
8932
|
+
if (!state || state.Status !== "exited" || typeof state.ExitCode !== "number" || (state.Error ?? "") !== "") {
|
|
8933
|
+
return {
|
|
8934
|
+
exit: null,
|
|
8935
|
+
secs,
|
|
8936
|
+
failure: "backend",
|
|
8937
|
+
reason: state?.Error ? `verifier container runtime error: ${state.Error}` : "Docker did not provide a trustworthy exited-container state"
|
|
8938
|
+
};
|
|
8939
|
+
}
|
|
8940
|
+
return { exit: state.ExitCode, secs };
|
|
8941
|
+
}
|
|
8898
8942
|
function runContainerStage(backend, dir, command, budgetSecs) {
|
|
8899
8943
|
const t0 = Date.now();
|
|
8900
8944
|
if (backend.kind !== "container" || !backend.available || !backend.image || !backend.enginePath || !backend.engineSha256 || !backend.daemonHost || !engineStable(backend)) {
|
|
@@ -8914,7 +8958,8 @@ function runContainerStage(backend, dir, command, budgetSecs) {
|
|
|
8914
8958
|
workspace: resolve5(dir),
|
|
8915
8959
|
command,
|
|
8916
8960
|
uid,
|
|
8917
|
-
gid
|
|
8961
|
+
gid,
|
|
8962
|
+
resources: backend.resources ?? DEFAULT_CONTAINER_RESOURCE_LIMITS
|
|
8918
8963
|
});
|
|
8919
8964
|
try {
|
|
8920
8965
|
const r = spawnSync(
|
|
@@ -8970,15 +9015,7 @@ function runContainerStage(backend, dir, command, budgetSecs) {
|
|
|
8970
9015
|
}
|
|
8971
9016
|
}
|
|
8972
9017
|
cleanupContainer(backend, name);
|
|
8973
|
-
|
|
8974
|
-
return {
|
|
8975
|
-
exit: null,
|
|
8976
|
-
secs,
|
|
8977
|
-
failure: "backend",
|
|
8978
|
-
reason: state?.OOMKilled ? "verifier container was OOM-killed" : state?.Error ? `verifier container runtime error: ${state.Error}` : "Docker did not provide a trustworthy exited-container state"
|
|
8979
|
-
};
|
|
8980
|
-
}
|
|
8981
|
-
return { exit: state.ExitCode, secs };
|
|
9018
|
+
return containerStateResult(state, secs);
|
|
8982
9019
|
} catch (e) {
|
|
8983
9020
|
cleanupContainer(backend, name);
|
|
8984
9021
|
return {
|
|
@@ -9471,19 +9508,23 @@ function runVerify(opts) {
|
|
|
9471
9508
|
return 2;
|
|
9472
9509
|
}
|
|
9473
9510
|
const visible = runStage(visDir);
|
|
9474
|
-
if (visible.failure === "backend") {
|
|
9511
|
+
if (visible.failure === "backend" || visible.failure === "resource") {
|
|
9475
9512
|
cleanup([visRoot]);
|
|
9513
|
+
const exhausted = visible.failure === "resource";
|
|
9476
9514
|
if (opts.json) {
|
|
9477
9515
|
out2(JSON.stringify({
|
|
9478
9516
|
verdict: "CANNOT_VERIFY",
|
|
9479
|
-
reason: "VERIFIER_BACKEND_RUNTIME_FAILURE",
|
|
9517
|
+
reason: exhausted ? "VERIFIER_RESOURCE_EXHAUSTED" : "VERIFIER_BACKEND_RUNTIME_FAILURE",
|
|
9480
9518
|
stage: "visible",
|
|
9519
|
+
...visible.resource ? { resource: visible.resource } : {},
|
|
9481
9520
|
detail: visible.reason,
|
|
9482
9521
|
verifier_backend: backendReport(),
|
|
9483
9522
|
oracle_assurance: oracleAssuranceReport()
|
|
9484
9523
|
}));
|
|
9485
9524
|
} else {
|
|
9486
|
-
out2(
|
|
9525
|
+
out2(
|
|
9526
|
+
exhausted ? "verify: isolated verifier resource envelope was exhausted during the visible stage \u2014 failing closed" : "verify: isolated verifier backend failed while running the visible stage \u2014 failing closed"
|
|
9527
|
+
);
|
|
9487
9528
|
if (visible.reason) out2("verify: " + visible.reason);
|
|
9488
9529
|
}
|
|
9489
9530
|
return 2;
|
|
@@ -9523,19 +9564,23 @@ function runVerify(opts) {
|
|
|
9523
9564
|
}
|
|
9524
9565
|
const overlayBefore = overlayDigest(priDir, restored);
|
|
9525
9566
|
const pristine = runStage(priDir);
|
|
9526
|
-
if (pristine.failure === "backend") {
|
|
9567
|
+
if (pristine.failure === "backend" || pristine.failure === "resource") {
|
|
9527
9568
|
cleanup([visRoot, priRoot]);
|
|
9569
|
+
const exhausted = pristine.failure === "resource";
|
|
9528
9570
|
if (opts.json) {
|
|
9529
9571
|
out2(JSON.stringify({
|
|
9530
9572
|
verdict: "CANNOT_VERIFY",
|
|
9531
|
-
reason: "VERIFIER_BACKEND_RUNTIME_FAILURE",
|
|
9573
|
+
reason: exhausted ? "VERIFIER_RESOURCE_EXHAUSTED" : "VERIFIER_BACKEND_RUNTIME_FAILURE",
|
|
9532
9574
|
stage: "pristine",
|
|
9575
|
+
...pristine.resource ? { resource: pristine.resource } : {},
|
|
9533
9576
|
detail: pristine.reason,
|
|
9534
9577
|
verifier_backend: backendReport(),
|
|
9535
9578
|
oracle_assurance: oracleAssuranceReport()
|
|
9536
9579
|
}));
|
|
9537
9580
|
} else {
|
|
9538
|
-
out2(
|
|
9581
|
+
out2(
|
|
9582
|
+
exhausted ? "verify: isolated verifier resource envelope was exhausted during the pristine stage \u2014 failing closed" : "verify: isolated verifier backend failed while running the pristine stage \u2014 failing closed"
|
|
9583
|
+
);
|
|
9539
9584
|
if (pristine.reason) out2("verify: " + pristine.reason);
|
|
9540
9585
|
}
|
|
9541
9586
|
return 2;
|
|
@@ -9649,10 +9694,227 @@ function parseVerify(args) {
|
|
|
9649
9694
|
|
|
9650
9695
|
// src/cli/run.ts
|
|
9651
9696
|
import { execFileSync as execFileSync9, spawnSync as spawnSync3 } from "node:child_process";
|
|
9652
|
-
import { readdirSync as readdirSync4, readFileSync as readFileSync16, readlinkSync as readlinkSync4, realpathSync as realpathSync6 } from "node:fs";
|
|
9653
|
-
import {
|
|
9697
|
+
import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync4, readFileSync as readFileSync16, readlinkSync as readlinkSync4, realpathSync as realpathSync6, rmSync as rmSync4 } from "node:fs";
|
|
9698
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
9699
|
+
import { join as join16, resolve as resolve7 } from "node:path";
|
|
9654
9700
|
var out = (s) => process.stdout.write(s + "\n");
|
|
9655
9701
|
var err = (s) => process.stderr.write(s + "\n");
|
|
9702
|
+
var AGENT_SUPERVISOR = String.raw`
|
|
9703
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
9704
|
+
const fs = require('node:fs');
|
|
9705
|
+
|
|
9706
|
+
const [resultFile, budgetRaw, command, ...args] = process.argv.slice(1);
|
|
9707
|
+
const budgetMs = Number(budgetRaw);
|
|
9708
|
+
let child;
|
|
9709
|
+
let timedOut = false;
|
|
9710
|
+
let finished = false;
|
|
9711
|
+
|
|
9712
|
+
function writeResult(value) {
|
|
9713
|
+
try { fs.writeFileSync(resultFile, JSON.stringify(value)); } catch {}
|
|
9714
|
+
}
|
|
9715
|
+
|
|
9716
|
+
function linuxDescendants(rootPid) {
|
|
9717
|
+
if (process.platform !== 'linux') return [];
|
|
9718
|
+
const byParent = new Map();
|
|
9719
|
+
let names = [];
|
|
9720
|
+
try { names = fs.readdirSync('/proc').filter((x) => /^\d+$/.test(x)); } catch { return []; }
|
|
9721
|
+
for (const name of names) {
|
|
9722
|
+
const pid = Number(name);
|
|
9723
|
+
try {
|
|
9724
|
+
const stat = fs.readFileSync('/proc/' + name + '/stat', 'utf8');
|
|
9725
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
9726
|
+
const ppid = Number(fields[1]);
|
|
9727
|
+
if (!Number.isFinite(ppid)) continue;
|
|
9728
|
+
const kids = byParent.get(ppid) || [];
|
|
9729
|
+
kids.push(pid);
|
|
9730
|
+
byParent.set(ppid, kids);
|
|
9731
|
+
} catch {}
|
|
9732
|
+
}
|
|
9733
|
+
const out = [];
|
|
9734
|
+
const seen = new Set([rootPid]);
|
|
9735
|
+
const stack = [rootPid];
|
|
9736
|
+
while (stack.length) {
|
|
9737
|
+
const parent = stack.pop();
|
|
9738
|
+
for (const childPid of (byParent.get(parent) || [])) {
|
|
9739
|
+
if (seen.has(childPid)) continue;
|
|
9740
|
+
seen.add(childPid);
|
|
9741
|
+
out.push(childPid);
|
|
9742
|
+
stack.push(childPid);
|
|
9743
|
+
}
|
|
9744
|
+
}
|
|
9745
|
+
return out;
|
|
9746
|
+
}
|
|
9747
|
+
|
|
9748
|
+
function killTree(pid) {
|
|
9749
|
+
if (!pid) return;
|
|
9750
|
+
if (process.platform === 'win32') {
|
|
9751
|
+
try { spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
|
|
9752
|
+
return;
|
|
9753
|
+
}
|
|
9754
|
+
|
|
9755
|
+
// Snapshot Linux descendants BEFORE killing the process-group leader. A
|
|
9756
|
+
// descendant may have called setsid() and escaped the group while remaining
|
|
9757
|
+
// in the agent's parent/child tree; /proc still lets us identify it.
|
|
9758
|
+
const descendants = linuxDescendants(pid);
|
|
9759
|
+
try { process.kill(-pid, 'SIGKILL'); } catch {
|
|
9760
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
9761
|
+
}
|
|
9762
|
+
for (const childPid of descendants.reverse()) {
|
|
9763
|
+
try { process.kill(childPid, 'SIGKILL'); } catch {}
|
|
9764
|
+
}
|
|
9765
|
+
}
|
|
9766
|
+
|
|
9767
|
+
try {
|
|
9768
|
+
child = spawn(command, args, {
|
|
9769
|
+
stdio: 'inherit',
|
|
9770
|
+
detached: process.platform !== 'win32',
|
|
9771
|
+
});
|
|
9772
|
+
} catch (e) {
|
|
9773
|
+
writeResult({ exit: 1, timedOut: false, failure: String(e) });
|
|
9774
|
+
process.exit(0);
|
|
9775
|
+
}
|
|
9776
|
+
|
|
9777
|
+
writeResult({ pid: child.pid, started: true });
|
|
9778
|
+
|
|
9779
|
+
const timer = setTimeout(() => {
|
|
9780
|
+
timedOut = true;
|
|
9781
|
+
killTree(child.pid);
|
|
9782
|
+
}, budgetMs);
|
|
9783
|
+
timer.unref();
|
|
9784
|
+
|
|
9785
|
+
child.once('error', (e) => {
|
|
9786
|
+
if (finished) return;
|
|
9787
|
+
finished = true;
|
|
9788
|
+
clearTimeout(timer);
|
|
9789
|
+
writeResult({ exit: 1, timedOut, failure: String(e) });
|
|
9790
|
+
process.exit(0);
|
|
9791
|
+
});
|
|
9792
|
+
|
|
9793
|
+
child.once('exit', (code, signal) => {
|
|
9794
|
+
if (finished) return;
|
|
9795
|
+
finished = true;
|
|
9796
|
+
clearTimeout(timer);
|
|
9797
|
+
// A timeout owns the public status even when the killed process reports a
|
|
9798
|
+
// shell-specific signal/exit value. The envelope will still adjudicate.
|
|
9799
|
+
writeResult({
|
|
9800
|
+
exit: timedOut ? 124 : (code == null ? 1 : code),
|
|
9801
|
+
timedOut,
|
|
9802
|
+
signal: signal == null ? null : String(signal),
|
|
9803
|
+
});
|
|
9804
|
+
process.exit(0);
|
|
9805
|
+
});
|
|
9806
|
+
`;
|
|
9807
|
+
function linuxDescendantPids(rootPid) {
|
|
9808
|
+
if (process.platform !== "linux") return [];
|
|
9809
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
9810
|
+
let names;
|
|
9811
|
+
try {
|
|
9812
|
+
names = readdirSync4("/proc").filter((x) => /^\d+$/.test(x));
|
|
9813
|
+
} catch {
|
|
9814
|
+
return [];
|
|
9815
|
+
}
|
|
9816
|
+
for (const name of names) {
|
|
9817
|
+
const pid = Number(name);
|
|
9818
|
+
try {
|
|
9819
|
+
const stat = readFileSync16(`/proc/${name}/stat`, "utf8");
|
|
9820
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
9821
|
+
const ppid = Number(fields[1]);
|
|
9822
|
+
if (!Number.isFinite(ppid)) continue;
|
|
9823
|
+
const kids = byParent.get(ppid) ?? [];
|
|
9824
|
+
kids.push(pid);
|
|
9825
|
+
byParent.set(ppid, kids);
|
|
9826
|
+
} catch {
|
|
9827
|
+
}
|
|
9828
|
+
}
|
|
9829
|
+
const out2 = [];
|
|
9830
|
+
const seen = /* @__PURE__ */ new Set([rootPid]);
|
|
9831
|
+
const stack = [rootPid];
|
|
9832
|
+
while (stack.length) {
|
|
9833
|
+
const parent = stack.pop();
|
|
9834
|
+
for (const childPid of byParent.get(parent) ?? []) {
|
|
9835
|
+
if (seen.has(childPid)) continue;
|
|
9836
|
+
seen.add(childPid);
|
|
9837
|
+
out2.push(childPid);
|
|
9838
|
+
stack.push(childPid);
|
|
9839
|
+
}
|
|
9840
|
+
}
|
|
9841
|
+
return out2;
|
|
9842
|
+
}
|
|
9843
|
+
function killAgentTree(pid) {
|
|
9844
|
+
if (!Number.isInteger(pid) || pid <= 1) return;
|
|
9845
|
+
if (process.platform === "win32") {
|
|
9846
|
+
try {
|
|
9847
|
+
spawnSync3("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5e3 });
|
|
9848
|
+
} catch {
|
|
9849
|
+
}
|
|
9850
|
+
return;
|
|
9851
|
+
}
|
|
9852
|
+
const descendants = linuxDescendantPids(pid);
|
|
9853
|
+
try {
|
|
9854
|
+
process.kill(-pid, "SIGKILL");
|
|
9855
|
+
} catch {
|
|
9856
|
+
try {
|
|
9857
|
+
process.kill(pid, "SIGKILL");
|
|
9858
|
+
} catch {
|
|
9859
|
+
}
|
|
9860
|
+
}
|
|
9861
|
+
for (const childPid of descendants.reverse()) {
|
|
9862
|
+
try {
|
|
9863
|
+
process.kill(childPid, "SIGKILL");
|
|
9864
|
+
} catch {
|
|
9865
|
+
}
|
|
9866
|
+
}
|
|
9867
|
+
}
|
|
9868
|
+
function runAgentWithBudget(argv2, cwd, budgetSecs) {
|
|
9869
|
+
const stateDir = mkdtempSync3(join16(tmpdir3(), "tw-agent-supervisor-"));
|
|
9870
|
+
const resultFile = join16(stateDir, "result.json");
|
|
9871
|
+
let supervisorTimedOut = false;
|
|
9872
|
+
try {
|
|
9873
|
+
const supervisor = spawnSync3(
|
|
9874
|
+
process.execPath,
|
|
9875
|
+
["-e", AGENT_SUPERVISOR, resultFile, String(budgetSecs * 1e3), ...argv2],
|
|
9876
|
+
{
|
|
9877
|
+
cwd,
|
|
9878
|
+
stdio: "inherit",
|
|
9879
|
+
// Backstop only. The inner supervisor owns the intended budget and
|
|
9880
|
+
// process-tree kill. This prevents a broken supervisor hanging run.
|
|
9881
|
+
timeout: Math.ceil(budgetSecs * 1e3) + 1e4,
|
|
9882
|
+
killSignal: "SIGKILL"
|
|
9883
|
+
}
|
|
9884
|
+
);
|
|
9885
|
+
supervisorTimedOut = Boolean(supervisor.error) && supervisor.error.code === "ETIMEDOUT";
|
|
9886
|
+
let state = {};
|
|
9887
|
+
try {
|
|
9888
|
+
state = JSON.parse(readFileSync16(resultFile, "utf8"));
|
|
9889
|
+
} catch {
|
|
9890
|
+
}
|
|
9891
|
+
if (supervisorTimedOut || state.timedOut) {
|
|
9892
|
+
if (state.pid) killAgentTree(state.pid);
|
|
9893
|
+
return {
|
|
9894
|
+
exit: 124,
|
|
9895
|
+
timedOut: true,
|
|
9896
|
+
signal: state.signal ?? null,
|
|
9897
|
+
...state.failure ? { failure: state.failure } : {}
|
|
9898
|
+
};
|
|
9899
|
+
}
|
|
9900
|
+
if (typeof state.exit === "number") {
|
|
9901
|
+
return {
|
|
9902
|
+
exit: state.exit,
|
|
9903
|
+
timedOut: false,
|
|
9904
|
+
signal: state.signal ?? null,
|
|
9905
|
+
...state.failure ? { failure: state.failure } : {}
|
|
9906
|
+
};
|
|
9907
|
+
}
|
|
9908
|
+
if (state.pid) killAgentTree(state.pid);
|
|
9909
|
+
return {
|
|
9910
|
+
exit: 1,
|
|
9911
|
+
timedOut: false,
|
|
9912
|
+
failure: state.failure ?? "agent supervisor did not produce a final status"
|
|
9913
|
+
};
|
|
9914
|
+
} finally {
|
|
9915
|
+
rmSync4(stateDir, { recursive: true, force: true });
|
|
9916
|
+
}
|
|
9917
|
+
}
|
|
9656
9918
|
function git3(args, cwd) {
|
|
9657
9919
|
return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
9658
9920
|
}
|
|
@@ -9714,6 +9976,12 @@ function runEnvelope(opts) {
|
|
|
9714
9976
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
9715
9977
|
return 2;
|
|
9716
9978
|
}
|
|
9979
|
+
if (opts.agentBudget !== void 0 && (!Number.isFinite(opts.agentBudget) || opts.agentBudget <= 0)) {
|
|
9980
|
+
err(
|
|
9981
|
+
`tamperward run: --agent-budget needs a positive number of seconds (got ${JSON.stringify(opts.agentBudget)}) \u2014 failing closed.`
|
|
9982
|
+
);
|
|
9983
|
+
return 2;
|
|
9984
|
+
}
|
|
9717
9985
|
let base;
|
|
9718
9986
|
try {
|
|
9719
9987
|
base = git3(["rev-parse", "--verify", `${opts.base ?? "HEAD"}^{commit}`], cwd).trim();
|
|
@@ -9775,8 +10043,25 @@ function runEnvelope(opts) {
|
|
|
9775
10043
|
}
|
|
9776
10044
|
const rewritesBefore = objectRewriteState(cwd);
|
|
9777
10045
|
const spawnTicks = nowTicks();
|
|
9778
|
-
const
|
|
9779
|
-
|
|
10046
|
+
const agentRun = opts.agentBudget === void 0 ? (() => {
|
|
10047
|
+
const agent = spawnSync3(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
|
|
10048
|
+
return {
|
|
10049
|
+
exit: agent.status ?? 1,
|
|
10050
|
+
timedOut: false,
|
|
10051
|
+
signal: agent.signal ? String(agent.signal) : null,
|
|
10052
|
+
...agent.error ? { failure: agent.error.message } : {}
|
|
10053
|
+
};
|
|
10054
|
+
})() : runAgentWithBudget(opts.argv, cwd, opts.agentBudget);
|
|
10055
|
+
const agentExit = agentRun.exit;
|
|
10056
|
+
const agentTimedOut = agentRun.timedOut;
|
|
10057
|
+
if (agentRun.failure && !agentTimedOut) {
|
|
10058
|
+
err(`tamperward run: agent runtime failed to start/report cleanly (${agentRun.failure}) \u2014 continuing to adjudicate the released tree.`);
|
|
10059
|
+
}
|
|
10060
|
+
if (agentTimedOut) {
|
|
10061
|
+
err(
|
|
10062
|
+
`tamperward run: AGENT_TIMEOUT \u2014 wrapped agent exceeded ${opts.agentBudget}s; its owned process tree was terminated. Post-timeout adjudication continues.`
|
|
10063
|
+
);
|
|
10064
|
+
}
|
|
9780
10065
|
if (objectRewriteState(cwd) !== rewritesBefore) {
|
|
9781
10066
|
err("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
|
|
9782
10067
|
err("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
|
|
@@ -9857,10 +10142,11 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
9857
10142
|
const cannot = !notQuiescent && (diffCode === 2 || workCode === 2 || verifyCode === 2);
|
|
9858
10143
|
const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
|
|
9859
10144
|
const enforcement = cannot ? 2 : blocked ? 1 : 0;
|
|
9860
|
-
const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : rewrote ? "OBJECT_REWRITE \u2014 the base the checks read is not the base we anchored to" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
|
|
10145
|
+
const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : rewrote ? "OBJECT_REWRITE \u2014 the base the checks read is not the base we anchored to" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentTimedOut ? "AGENT_TIMEOUT \u2014 runtime budget expired; post-timeout enforcement clean" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
|
|
10146
|
+
const agentSummary = agentTimedOut ? `AGENT_TIMEOUT (${opts.agentBudget}s; exit 124)` : `agent exit ${agentExit}`;
|
|
9861
10147
|
out(`
|
|
9862
|
-
tamperward run \u2014
|
|
9863
|
-
return enforcement !== 0 ? enforcement : agentExit;
|
|
10148
|
+
tamperward run \u2014 ${agentSummary}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
|
|
10149
|
+
return enforcement !== 0 ? enforcement : agentTimedOut ? 124 : agentExit;
|
|
9864
10150
|
}
|
|
9865
10151
|
function parseRun(args) {
|
|
9866
10152
|
const o = { argv: [] };
|
|
@@ -9872,6 +10158,7 @@ function parseRun(args) {
|
|
|
9872
10158
|
} else if (a === "--base") o.base = args[++i];
|
|
9873
10159
|
else if (a === "--cmd") o.cmd = args[++i];
|
|
9874
10160
|
else if (a === "--budget") o.budget = Number(args[++i]);
|
|
10161
|
+
else if (a === "--agent-budget") o.agentBudget = Number(args[++i]);
|
|
9875
10162
|
else if (a === "--allow-dirty") o.allowDirty = true;
|
|
9876
10163
|
else if (a === "--settle") o.settle = Number(args[++i]);
|
|
9877
10164
|
else if (a === "--allow-dep-drift") o.allowDepDrift = true;
|
|
@@ -9969,13 +10256,14 @@ Formats:
|
|
|
9969
10256
|
fails closed (2)
|
|
9970
10257
|
tamperward run [opts] -- <agent cmd...> enforcement envelope: record the trusted
|
|
9971
10258
|
[--base R] [--cmd C] base, run the agent, treat its exit as
|
|
9972
|
-
[--budget S] [--
|
|
9973
|
-
[--
|
|
9974
|
-
[--cwd D]
|
|
9975
|
-
|
|
9976
|
-
|
|
9977
|
-
|
|
9978
|
-
|
|
10259
|
+
[--budget S] [--agent-budget S] untrusted, then re-adjudicate the tree it
|
|
10260
|
+
[--allow-dirty] [--settle S] left (policy over base...HEAD and the
|
|
10261
|
+
[--allow-dep-drift] [--cwd D] worktree, plus verify). --agent-budget
|
|
10262
|
+
bounds the agent runtime itself and still
|
|
10263
|
+
adjudicates after timeout. Exit: agent's
|
|
10264
|
+
code when clean; 124 on clean AGENT_TIMEOUT;
|
|
10265
|
+
1 on any blocking finding/masked failure;
|
|
10266
|
+
2 when it cannot adjudicate (fails closed)
|
|
9979
10267
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
9980
10268
|
[--file F] [--cwd D]
|
|
9981
10269
|
tamperward init [--dry-run] wire the policy file plus every
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.3",
|
|
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",
|