mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 12:52:04 +00:00
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:
1 parent
c2a6a11472
commit
12b945ca7e
3 files changed
+167
-16
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user