test(agent): pin token expiry at its exact boundary; give the daemon a clock

Mutation testing found `token.expires_at <= now` could become `<` with every
test still passing. The suite checked well before and well after expiry, which
cannot tell the two apart — so a token was free to be usable for one more
request than it claimed, which is the wrong direction for a credential.

The daemon now takes an injectable clock, so the boundary can be asserted
exactly rather than raced against wall time: at T-1 the token works, at
exactly T it is refused. Every expiry decision AND every expiry assignment
goes through that clock — a token issued against real time while checked
against test time would never expire, which would have made the test
meaningless. The TOTP timestamp-skew window uses it too, since a clock that
moved one but not the other is a difference no production path should have
either.

Also pins the owner capability: an empty or absent capability is refused, and
so is one of the right length but wrong content — the latter proving the
constant-time comparison is what rejects it rather than the length check.

One survivor is now recorded as genuinely unkillable rather than chased.
`!presented || !this.capability` cannot change behaviour when flipped to `&&`,
because the length comparison below rejects an empty string either way. The
harness learned a `mutation-equivalent:` marker that must carry a REASON, so
future survivor lists mean "look at this" instead of "this again" — and the
judgement stays re-checkable rather than merely trusted.

The harness also gained `--offset`, so a long target is measured in
foreground-sized slices. That is a safety property, not a convenience: this
script writes broken security code into the working tree, and it must never
run unattended alongside commits again.

agent slice 1 (mutants 1-6): 100% of viable mutants caught.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 15:47:12 -04:00
1 parent c2a6a11472
commit 12b945ca7e
3 files changed
+167 -16

No files matched your search

+21 -7
View File
@@ -173,6 +173,13 @@ export class AgentDaemon {
private readonly defaultTtl = DEFAULT_TTL_SECONDS,
/** Profile root; required for token-mode vault/secret serving. */
private readonly appDir?: string,
/**
* Seconds since the epoch. Injectable so expiry can be tested at its
* exact boundary rather than approximately — "expires at T" has to mean
* denied AT T, and a test that only checks T-1 and T+1 cannot tell
* `<=` from `<`.
*/
private readonly now: () => number = () => Date.now() / 1000,
) {}
/**
@@ -203,6 +210,10 @@ export class AgentDaemon {
*/
private isOwner(msg: Record<string, unknown>): boolean {
const presented = String(msg["cap"] ?? "");
// mutation-equivalent: turning this `||` into `&&` cannot change the
// outcome — an empty string on either side fails the length comparison
// below, so the early return is a statement of intent rather than the
// thing doing the rejecting. Kept because the rule is worth stating.
if (!presented || !this.capability) return false;
const a = Buffer.from(presented);
const b = Buffer.from(this.capability);
@@ -261,7 +272,7 @@ export class AgentDaemon {
);
if (!token) return { deny: "unknown token" };
if (token.revoked) return { deny: "token revoked" };
if (token.expires_at <= Date.now() / 1000) return { deny: "token expired" };
if (token.expires_at <= this.now()) return { deny: "token expired" };
if (token.uses_remaining <= 0) return { deny: "token exhausted" };
if (!token.scopes.includes(action)) return { deny: `scope '${action}' not granted` };
// "read" is not consumption; secret-bearing actions decrement uses
@@ -408,7 +419,10 @@ export class AgentDaemon {
let ts: number | undefined;
if (msg["timestamp"] !== undefined) {
const requested = Number(msg["timestamp"]);
const now = Math.floor(Date.now() / 1000);
// Same clock as expiry: a test clock that moved one but not the other
// would let a token expire while the skew window still measured real
// time, which is a difference no production path should have either.
const now = Math.floor(this.now());
if (!Number.isFinite(requested) || Math.abs(requested - now) > TIMESTAMP_SKEW_SECONDS) {
return {
error: {
@@ -502,7 +516,7 @@ export class AgentDaemon {
}
private expire(): void {
const now = Date.now() / 1000;
const now = this.now();
for (const [fp, held] of this.held) {
if (held.expiresAt <= now) this.forget(fp);
}
@@ -523,7 +537,7 @@ export class AgentDaemon {
private highRiskTag(fingerprint: string): string {
const session = this.highRisk.get(fingerprint);
if (!session) return "";
if (session.expiresAt <= Date.now() / 1000) {
if (session.expiresAt <= this.now()) {
this.highRisk.delete(fingerprint);
return "";
}
@@ -585,7 +599,7 @@ export class AgentDaemon {
scopes: scopes as TokenScope[],
kinds: (kinds as string[] | undefined) ?? null,
label_regex: String(msg["label_regex"] ?? ".*"),
expires_at: Math.floor(Date.now() / 1000 + ttl),
expires_at: Math.floor(this.now() + ttl),
uses_remaining: uses,
revoked: false,
...(Array.isArray(msg["exec_allowlist"]) && (msg["exec_allowlist"] as string[]).length
@@ -738,7 +752,7 @@ export class AgentDaemon {
if (generateFingerprint(mnemonic) !== fingerprint) {
return { ok: false, error: "fingerprint does not match the supplied seed" };
}
const expiresAt = Math.floor(Date.now() / 1000 + ttl);
const expiresAt = Math.floor(this.now() + ttl);
// The audit key is derived from the held seed, so the seed must go in
// before the record is written — but an audit failure must then undo
// it. Otherwise a `put` that reports ok:false can leave the seed
@@ -774,7 +788,7 @@ export class AgentDaemon {
}
const ttl = positiveIntField(msg["ttl"], 300);
if (ttl === null) return { ok: false, error: "ttl must be a positive integer" };
const expiresAt = Math.floor(Date.now() / 1000 + ttl);
const expiresAt = Math.floor(this.now() + ttl);
this.highRisk.set(fingerprint, { tag, expiresAt });
try {
// Records THAT it was unlocked and for how long — never the tag.