Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fleetorders-repo-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"peirad": patch
---

package.json now ships repository/homepage/bugs links pointing at the fleetorders GitHub org — the npm page gains a Repository link.
5 changes: 5 additions & 0 deletions .changeset/script-probe-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"peirad": minor
---

New `script` probe type: the manifest names a repo-relative executable (plus optional args and `timeoutMs`), and the doctor pass runs it — exit 0 passes, exit 1 fails with the script's stdout as the finding, and exit 2 (or any other non-verdict outcome: not executable, killed by the timeout, crashed) reports `n/a` — no verdict. The probe fails open: a broken probe can only report `n/a`, never block — even when marked critical. Running a manifest's scripts is running that repo's code, the same trust as its npm scripts.
27 changes: 27 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,30 @@ profile points at (`exec --help`, not root `--help`). Verdicts and triage
output carry the profile used, and the triage `usage` accounting is whatever
the harness reports — codex reports token counts but neither model nor cost,
so those render as absent rather than invented.

### D-006 — A `script` probe runs repo code, and fails open on anything but its verdict

**Scope:** repo · **Decided:** 2026-09-01

The `script` probe type executes an executable the manifest's own repo
provides: exit 0 passes, exit 1 fails (the script's stdout is the finding,
`critical` still escalates to blocked), and exit 2 — or any other non-verdict
outcome: missing, not executable, killed by the timeout, crashed — reports
`n/a`, never a fail. The trust posture is explicit and documented: running a
manifest's scripts is running that repo's code, the same trust as its npm
scripts; a manifest is only as trustworthy as the repo that ships it.

**Why:** some checks a manifest needs cannot be expressed by the built-in
probes — they need to _do_ something (send an image, call a provider, compare
a reply against a known-by-construction expectation). The exit-code contract
(0 pass / 1 fail / 2 no-verdict) mirrors the warn-first, fail-open convention
those checks already follow: a probe that cannot deliver its verdict must
never be read as the integration breaking, so n/a outranks critical, and the
engine stays generic — the check's logic lives in the repo's script, not the
engine.

**Consequences:** a repo can now fold its bespoke checks into the same dated
verdict as every built-in probe; conversely, anyone running a foreign
manifest is executing that repo's code by design. The n/a register doubles as
the script's own error channel, so a genuinely broken integration and a
broken probe are always distinguishable in the output.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,21 @@ You declare probes; each runs against the live harness:
| `config-key` | the settings keys you rely on still exist |
| `hook-registered` | your hook is still wired for its event |
| `transcript-field` | the fields your tool reads from transcripts are still present |
| `script` | a repo-provided check still passes |

A non-critical probe that drifts reports `degraded`; a probe marked `critical`
reports `blocked`; a probe its [harness profile](#harness-profiles) says
cannot apply reports `n/a`. Nothing throws — one drift never hides the next.

The `script` probe runs an executable from the repo the manifest lives in:
exit 0 passes, exit 1 fails with the script's stdout as the finding, and
exit 2 reports `n/a` — no verdict. Any other outcome (not executable, killed
by the timeout, crashed) is read the same way: the probe fails open, so a
broken probe can only report `n/a`, never claim your integration broke —
even when marked `critical`. Running a manifest's scripts is running that
repo's code, the same trust as its npm scripts: a manifest is only as
trustworthy as the repo that ships it.

## How it works

peirad is a generic engine plus a per-project manifest. The manifest is data —
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@
"resilience"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/fleetorders/peirad.git"
},
"homepage": "https://github.com/fleetorders/peirad#readme",
"bugs": {
"url": "https://github.com/fleetorders/peirad/issues"
},
"engines": {
"node": ">=18.17"
},
Expand Down
6 changes: 6 additions & 0 deletions peirad.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
"type": "transcript-field",
"glob": "projects/**/*.jsonl",
"fields": ["type", "message"]
},
{
"type": "script",
"script": "scripts/check.sh",
"args": ["--quick"],
"timeoutMs": 60000
}
]
}
9 changes: 9 additions & 0 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export type ProbeSpec =
event: string;
match: string;
critical?: boolean;
}
| {
type: "script";
/** Repo-relative path to an executable script (resolved against configDir). */
script: string;
args?: string[];
/** Kill the script after this many ms (default 30_000). */
timeoutMs?: number;
critical?: boolean;
};

export interface Manifest {
Expand Down
70 changes: 70 additions & 0 deletions src/probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,23 @@ function probeLabel(spec: ProbeSpec, harness: string): string {
return `transcript-field(${spec.glob})`;
case "hook-registered":
return `hook-registered(${spec.event}~${spec.match})`;
case "script":
return `script(${[spec.script, ...(spec.args ?? [])].join(" ")})`;
}
}

/** Fold a script's output into one reportable line: leading non-empty lines,
* joined with " · ", capped so a chatty finding can't wreck the render. */
function fold(out: string, maxLines = 3, maxChars = 300): string {
const joined = out
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.slice(0, maxLines)
.join(" · ");
return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined;
}

export function runProbe(
spec: ProbeSpec,
ctx: ProbeContext,
Expand Down Expand Up @@ -276,5 +290,61 @@ export function runProbe(
: `no ${spec.event} hook matching "${spec.match}"`,
};
}
case "script": {
const label = `script(${[spec.script, ...(spec.args ?? [])].join(" ")})`;
const base = path.resolve(ctx.configDir);
const file = path.resolve(base, spec.script);
// A verdict the script never delivered is n/a, never a fail: the probe
// fails open, so a missing/unrunnable/overshooting script can only
// report "no verdict", never block — even when marked critical.
const na = (detail: string): ProbeResult => ({
probe: label,
status: "n/a",
detail,
});
if (!fs.existsSync(file)) {
return na(`script not found: ${spec.script}`);
}
const r = spawnSync(file, spec.args ?? [], {
encoding: "utf8",
cwd: base,
timeout: spec.timeoutMs ?? 30_000,
});
const out = `${r.stdout ?? ""}`;
if (r.error) {
// A timeout lands here as ETIMEDOUT, with or without a signal.
const code = (r.error as NodeJS.ErrnoException).code;
if (code === "ETIMEDOUT" || r.signal) {
return na(
`killed by ${r.signal ?? "timeout"} after ${spec.timeoutMs ?? 30_000}ms — no verdict`,
);
}
return na(
`could not run ${spec.script}: ${r.error.message} (must be executable)`,
);
}
if (r.signal) {
return na(`killed by ${r.signal} — no verdict`);
}
if (r.status === 0) {
return {
probe: label,
status: "pass",
detail: fold(out) || "exit 0",
};
}
if (r.status === 1) {
return {
probe: label,
status: fail(spec),
detail: fold(out) || "exit 1 (no output)",
};
}
// Exit 2 is the script's own "no verdict" channel; any other exit code
// is read the same way — fail-open, never a fail verdict.
return na(
`exit ${r.status}${out.trim() ? `: ${fold(out, 1)}` : " — no verdict"}`,
);
}
}
}
27 changes: 27 additions & 0 deletions test/fixtures/peirad-script.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "example: script probes",
"harness": "true",
"versionArgs": ["--version"],
"configDir": ".",
"probes": [
{ "type": "command-exists", "critical": true },
{
"type": "script",
"script": "script-probe.sh",
"args": ["pass"],
"critical": true
},
{
"type": "script",
"script": "script-probe.sh",
"args": ["fail"],
"critical": true
},
{
"type": "script",
"script": "script-probe.sh",
"args": ["error"],
"critical": true
}
]
}
21 changes: 21 additions & 0 deletions test/fixtures/script-probe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh
# Fixture for the script-probe acceptance run: a repo-provided check that
# demonstrates all three exit channels of the `script` probe contract.
# scripts/script-probe.sh pass → exit 0
# scripts/script-probe.sh fail → exit 1, stdout is the finding
# scripts/script-probe.sh error → exit 2, n/a (warn, never a verdict)
case "${1:-pass}" in
pass)
echo "CHECK PASSES — the thing this repo checks still holds"
exit 0
;;
fail)
echo "CHECK FAILS — the thing this repo checks drifted"
echo " expected: the contract the integration relies on"
exit 1
;;
*)
echo "CHECK ERROR — could not reach the thing it checks"
exit 2
;;
esac
64 changes: 64 additions & 0 deletions test/probes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,70 @@ describe("transcript-field", () => {
});
});

describe("script", () => {
const write = (name: string, body: string): string => {
const p = path.join(dir, name);
fs.writeFileSync(p, body);
fs.chmodSync(p, 0o755);
return p;
};
it("exit 0 passes and surfaces the script's stdout", () => {
write("ok.sh", '#!/bin/sh\necho "still holds"\nexit 0\n');
const r = runProbe({ type: "script", script: "ok.sh" }, ctx(), []);
expect(r.status).toBe("pass");
expect(r.detail).toContain("still holds");
});
it("exit 1 fails with stdout as the finding (critical → blocked)", () => {
write("bad.sh", '#!/bin/sh\necho "CHECK FAILS — thing drifted"\nexit 1\n');
expect(
runProbe({ type: "script", script: "bad.sh" }, ctx(), []).status,
).toBe("degraded");
const r = runProbe(
{ type: "script", script: "bad.sh", critical: true },
ctx(),
[],
);
expect(r.status).toBe("blocked");
expect(r.detail).toContain("CHECK FAILS — thing drifted");
});
it("exit 2 is n/a — even when critical (fails open)", () => {
write("na.sh", '#!/bin/sh\necho "probe error: no verdict"\nexit 2\n');
const r = runProbe(
{ type: "script", script: "na.sh", critical: true },
ctx(),
[],
);
expect(r.status).toBe("n/a");
expect(r.detail).toContain("probe error: no verdict");
});
it("passes args through and names them in the probe label", () => {
write("echo.sh", '#!/bin/sh\necho "arg: $1"\nexit 0\n');
const r = runProbe(
{ type: "script", script: "echo.sh", args: ["quick"] },
ctx(),
[],
);
expect(r.status).toBe("pass");
expect(r.probe).toBe("script(echo.sh quick)");
expect(r.detail).toContain("arg: quick");
});
it("a missing script is n/a, not a verdict", () => {
const r = runProbe({ type: "script", script: "nope.sh" }, ctx(), []);
expect(r.status).toBe("n/a");
expect(r.detail).toContain("script not found");
});
it("a timeout kills the script into the n/a register", () => {
write("slow.sh", "#!/bin/sh\nsleep 5\n");
const r = runProbe(
{ type: "script", script: "slow.sh", timeoutMs: 500 },
ctx(),
[],
);
expect(r.status).toBe("n/a");
expect(r.detail).toContain("killed");
});
});

describe("loadManifest", () => {
it("rejects a manifest without harness/probes", () => {
const bad = path.join(dir, "bad.json");
Expand Down
Loading