{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"68591e6d-16e0-4063-a0a2-c9166cfb87fb","kind":"audit","nodes":[{"acceptedSubmissionHash":"f5528c300d0e7e9642bec93d46d6817f844c9bc3f0963259ec376f79f35e31b3","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"5e2318655405afa1d904dba24c7d41229086104d2b027fe77e5f8e3a256dfd52","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"7dfe802ea7f629c482f55150161ae960d385f5a22222ea92b1a4c20c66b4936e","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"89d32ea43f2cfe4fc26b00d4b8bde631ef3557fe6f06daa51d05f159b9032b42","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"fcc559c2cde66cb56050bbd272f0232dae3d5d4a8035a6fb12b50e25d45d48fa","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Security audit of the whole NIGHTSHIFT repository (an autonomous AI company platform built on the IMD swarm, live at getnightshift.tech with real funds). Review: apps/engine (node:http API in server.ts, wallet sign-in and HMAC sessions in auth.ts, the AES-GCM key vault in vault.ts, company wallets and withdrawals, x402 USDC sales and signed download links, the admin dashboard and its token, the keeper that runs the fee splitter and IMD burns in keeper.ts, the Telegram bot and moderation, the MCP server and its API keys, Space Pinball's server replay), packages (core spending policy and shift loop, imd client and payments, x402 seller), and apps/web (every transaction the browser builds with viem: swap through Uniswap v4, staking, NFT claims, funding; the pinned contract addresses in shift.ts; the service workers; the CSP). Find anything that could move or lose funds, bypass ownership or admin checks, leak secrets or private data, enable XSS, SSRF or path traversal, or let a client cheat a game. Rank findings by severity with file and line and a concrete fix.","parentJobId":null,"planHash":"5ea99369d39ed94a3d3a8f738206d8a0589ed29caea5f200541992bd9ca9ae0b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"68591e6d-16e0-4063-a0a2-c9166cfb87fb","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52478","feedbackHash":"aae6d1bfb66ad84cb06d4c3e3d41ea3bef4465b6a4455c804c56db35642b0401","nodeKey":"audit_economics","submissionHash":"f5528c300d0e7e9642bec93d46d6817f844c9bc3f0963259ec376f79f35e31b3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51375","feedbackHash":"b93dcdd927cab36cc14daba5aca296efdcab9e3924a6bcc51396cf24d0f3208b","nodeKey":"audit_flow","submissionHash":"5e2318655405afa1d904dba24c7d41229086104d2b027fe77e5f8e3a256dfd52","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53932","feedbackHash":"992adce7c8c5fde0615d4b730a1c444b3e2f665cc631cd0c7dd7b4822764cf9f","nodeKey":"audit_judge","submissionHash":"7dfe802ea7f629c482f55150161ae960d385f5a22222ea92b1a4c20c66b4936e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51445","feedbackHash":"0119e73369c74639f12198c8110b0af57e4b2e05e374345edfdbfc7872d60bf1","nodeKey":"audit_math","submissionHash":"89d32ea43f2cfe4fc26b00d4b8bde631ef3557fe6f06daa51d05f159b9032b42","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51043","feedbackHash":"ef06fadae7d8d95235fe638d2480f48a9acb719441c6695ebaed8788dfba2008","nodeKey":"audit_permissions","submissionHash":"fcc559c2cde66cb56050bbd272f0232dae3d5d4a8035a6fb12b50e25d45d48fa","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"d097619ee337dd6ffcbb9bc1a1c374cc2a4bde8ab2b6559bdda2de26d930b32f","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"93c37f17670e4d98","findings":[{"citation":"resolved","description":"vault.json is the only copy of every company wallet key, the keeper key and the Bluesky app passwords (encrypted). `create()` (line 42) and `putSecret()` (line 56) overwrite the file with a single `writeFileSync` on the same path. `writeFileSync` opens the file with O_TRUNC first and then writes, so a crash, OOM kill (the shared-host compose caps the container at 384 MB), SIGKILL from `docker compose down` during a write, or ENOSPC leaves vault.json empty or truncated. On the next start the constructor does `JSON.parse(readFileSync(...))` on that file and throws, or loads a partial object missing the entries that were already funded. Unlike store.ts, which writes `state.json.tmp` and `renameSync`s it, there is no recovery copy. In live mode a company wallet holds the owner's IMD and ETH on Ethereum plus its USDC revenue on Base; those funds are unrecoverable without the key. Every new company and every Bluesky connect triggers this rewrite, so the exposure recurs for the lifetime of the deployment.","line":42,"path":"apps/engine/src/vault.ts","reproduction":"State: live mode with one funded company (vault.json holds `<companyId>` and `keeper`). Input: a second owner calls POST /api/companies (which calls vault.create(newId)) while the data volume is full (ENOSPC) or the process is killed between O_TRUNC and the write completing (e.g. `kill -9` of the node process timed during the call, or a container OOM). Expected: the existing company keys survive and only the new company fails. Actual: vault.json is truncated/empty; on restart `new Vault()` throws `SyntaxError: Unexpected end of JSON input` (or loads `{}`), `vault.account(companyId)` throws `no wallet for <id>`, and the funded company's wallet can never sign again: its USDC, IMD and ETH are lost. Fix: write to `${file}.tmp` and `renameSync` over the original (as store.ts does), keep the previous file as `vault.json.bak` before the rename, and refuse to start when the parse fails rather than silently continuing; additionally fsync before rename.","severity":"high","snippet":"    writeFileSync(this.#file, JSON.stringify(this.#entries, null, 2), { mode: 0o600 });","title":"Wallet vault is rewritten in place without an atomic rename: one interrupted write destroys every company private key"},{"citation":"resolved","description":"`settlePayment` (packages/x402/src/seller.ts:136-146) broadcasts `transferWithAuthorization` from the company wallet and then `waitForTransactionReceipt`s. Three ordinary conditions make the await at server.ts:624 reject after the transfer has already been broadcast and will be mined: the Base RPC returns an error or 429 while polling for the receipt, viem's receipt timeout elapses during a Base congestion spike, or the HTTP request/process is interrupted. The catch at the bottom of `serve()` then answers 400 \"Something went wrong\", `recordSale` never runs (no ledger entry, no download link, no alert), and the PAYMENT-RESPONSE header is never sent. The buyer's USDC is nevertheless transferred to the company wallet once the transaction lands, because EIP-3009 settlement does not depend on the server's bookkeeping. Nothing lets the buyer redeem the already-used authorization: a repeat request with the same payment hits `settlePayment` again, which reverts on chain (nonce already used, `authorizationState(from, nonce) == true`) and again returns 400, so the only way to get the product is to sign a second authorization and pay again. The company also under-reports revenue (ledger `co:<id>:usdc` is lower than the wallet balance), so `withdrawable()` leaves that USDC stranded until `closeOut`.","line":624,"path":"apps/engine/src/server.ts","reproduction":"State: live mode, company C with a live packaged product P priced 5 USDC, a buyer B with 10 USDC on Base. Input: B GETs /buy/C/P with a valid PAYMENT-SIGNATURE; make the sales RPC (config.salesRpc) answer eth_sendRawTransaction normally but fail eth_getTransactionReceipt / eth_blockNumber (e.g. point SALES_RPC at a proxy that drops those methods, or a provider that 429s). Expected: either the buyer is not charged, or the response carries the tx and the download link. Actual: the transaction is mined on Base and B's balance drops by 5 USDC; the server responds 400 with no `deliverable`; `engine.store.metricsOf(C.id).sales` and the ledger have no entry; retrying with the same header returns 400 (`settlement reverted`), retrying with a fresh signature charges B another 5 USDC. Fix: make settlement idempotent on the authorization. Before and after the send, read `authorizationState(from, nonce)` on USDC (or look up the transfer by nonce); if the authorization is already consumed with `to == c.wallet` and `value >= price`, treat it as paid: record the sale once (keyed by `${from}:${nonce}`) and return the deliverable link. Persist the pending (payer, nonce, product) before broadcasting so a crash can be reconciled at startup.","severity":"medium","snippet":"        const tx = await run;\n        await engine.recordSale(c, product.id, BigInt(payment.payload.authorization.value), v.payer, tx, false);","title":"x402 sale: USDC can leave the buyer on chain while the server records no sale and returns no download, so an honest retry charges the buyer twice"},{"citation":"resolved","description":"`buildEpoch` computes each wallet's earned hours as `now - s.hours[w]`, then unconditionally overwrites `s.hours = now` and `s.block = head` and saves, even when no token epoch was produced (`total <= 0n` for both ETH and SHIFT, or `sum <= 0`). The scheduler in engine.ts runs this every Monday 00:00 UTC while the keeper that produces `Distributed` events runs daily at 03:00 UTC; the launch page says the pot is 'shared by the shift hours each wallet earned that week'. Any week where the splitter emitted no `Distributed` event between `s.block` and head (quiet trading week, keeper out of gas, keeper not yet named, RPC `getLogs` returning an empty range) consumes every holder's hours for that week with zero payout; the next week's pot is then shared only by hours earned after the reset. The same happens if `potSince` succeeds but ETH alone is below dust. Holders lose the rewards their hours were supposed to buy, permanently, with no log line or alert.","line":62,"path":"apps/engine/src/frogpot.ts","reproduction":"State: frogpot.json with `block = N`, `hours = {}`; staking board has wallet W with 168 hours and wallet V with 0; no `Distributed` logs between N+1 and head (keeper skipped because the splitter held only dust). Input: `/api/admin/frogpot/build` (or the Monday timer) calls `buildEpoch(contracts, scanFrom)`. Expected: no epoch is created and W's 168 hours remain creditable toward the next epoch that has a pot. Actual: the function returns null, but frogpot.json now has `hours = { W: 168 }` and `block = head`; the following Monday 1 ETH arrives and W, who has 168 new hours, shares it with V who also has 168 new hours, so W's first 168 hours are never paid. Fix: only assign `s.hours = now` and `s.block = head` when at least one token epoch is built (move the two assignments below the `if (!tokens.length)` early return, and return without saving), or keep a separate carry-over so unpaid hours roll into the next epoch.","severity":"medium","snippet":"  s.hours = now;\n  s.block = head;\n  if (!tokens.length) { save(s); return null; }","title":"NIGHT FROGS epoch build discards all shift hours earned since the last epoch when the pot reads as empty"},{"citation":"resolved","description":"`publicUrl()` resolves the hostname with `dns.lookup`, rejects private answers, and then `safeFetch` calls `fetch(url, ...)` with the original hostname (line 40). undici performs its own DNS resolution for that request, so a hostname whose authoritative server alternates answers (TTL 0: first a public IP, then 127.0.0.1 / 172.17.x / 169.254.169.254) passes the check and connects to the internal address. The URLs come from untrusted content: `checkLinks` fetches every http(s) link in the IMD swarm's delivered product text, and `fetchDelivery` fetches `files` URLs from IMD job results. Port is limited to 80/443 and the body is not returned to the author, so the impact is a blind GET against the engine's own network (other containers on the shared `edge` network, the host's port 80/443, cloud metadata on 169.254.169.254:80 whose response is discarded but whose side effects, e.g. a GET on a neighbour's admin endpoint, are not).","line":28,"path":"apps/engine/src/net.ts","reproduction":"State: a product delivered by the swarm whose Markdown contains `http://rebind.example.net/x`, where rebind.example.net answers 93.184.216.34 on the first lookup and 172.18.0.5 (a container on the same Docker network) on the second, both with TTL 0. Input: the tick loop calls `files.ensure()` -> `checkLinks(body)` -> `safeFetch(u)`. Expected: the request is refused or only ever reaches 93.184.216.34. Actual: `lookup` sees the public address and passes, `fetch` resolves again and sends `GET /x` to 172.18.0.5:80; the markdown records the link as answering. Fix: pin the connection to the address that was checked: resolve once, then fetch with an undici `Agent` whose `connect.lookup` returns the vetted address (or build the request to the IP with a `Host` header and `servername` for TLS), and re-apply the same pinning on every redirect hop.","severity":"low","snippet":"    const addrs = isIP(host) ? [{ address: host }] : await lookup(host, { all: true });\n    if (!addrs.length || addrs.some((a) => privateIp(a.address))) return null;","title":"safeFetch checks DNS once and then lets fetch resolve again, so a rebinding host reaches internal services (blind SSRF)"},{"citation":"resolved","description":"Both players receive the seed 400 ms before the start (`view()` line 66) and the engine is byte-identical in the browser bundle and deterministic by design. The only anti-cheat is that the largest input tick may not exceed elapsed wall-clock plus 3 s and that the replay is done server-side. Nothing ties an input to the moment it was supposedly pressed: a modified client can, at any tick, fork the `Table`, try every flipper/plunger timing a few hundred ticks ahead, keep the best branch, and emit those inputs as its 'live' play, staying within the wall-clock bound at all times (the search runs in parallel with the real clock; 150 s of table time is cheap to simulate many times over). The result passes `replay()`/`duelReplay()` exactly, is posted to the community as 'Verified by replay' and sets the all-time board; once stakes in IMD/SHIFT go live (STAKES_LIVE) it wins real money against honest players. Progress reports (`progress()`) are explicitly 'never what decides', so they do not constrain this either.","line":142,"path":"apps/engine/src/pinball.ts","reproduction":"State: a duel match M with seed S started at T0. Input: player A's client, instead of reading key presses, runs a beam search over `new Table(S)` copies: every 60 ticks it evaluates candidate input sequences 240 ticks ahead and commits the one with the highest score, submitting `[tick, l, r, p]` entries only after `T0 + tick/240 s` has passed; at the end it POSTs the sequence to /api/pinball/match/M/finish. Expected: a score only a human playing live could reach. Actual: `finish()` accepts it (every tick <= elapsed), `settle()` replays it to a score far above any human run, A wins, the board records it and the bot announces a record. Fix: there is no complete fix for a client-side deterministic game, but the attack can be made expensive: require the client to commit a hash of each 1-second input window via `progress()` before the window's wall-clock time ends, and have `finish()` verify the submitted inputs against those commitments; add a per-match inputs-per-second sanity bound and a server-side 'reaction time' check (minimum ticks between a ball event and the flipper change that profits from it).","severity":"low","snippet":"  if (Date.now() - m.startedAt < (lastTick / 240) * 1000 - 3_000) throw new Error(\"inputs from later than the match has run\");","title":"Space Pinball replay only bounds the last input tick to wall-clock, so a client can compute optimal inputs with the shipped engine during the match"},{"citation":"resolved","description":"packages/core/src/shift.ts:117 creates product ids as `randomUUID().slice(0, 8)` (32 bits) and never checks them against existing products of other companies. `ProductFiles.#file` stores the private deliverable as `<dataDir>/products/<id>.md`, `has()`/`read()` look it up by id only, and `#sig()` signs `${id}|${exp}|${ref}` without the company. When two products in different companies share an id: `ensure()` for the second one returns early because `has(p)` is already true, so the second company sells the first company's file; a download link bought for one is valid at `/download/<otherSlug>/<id>/<exp>/<sig>` for the other; and `market()` lists the second product as packaged although its own delivery was never fetched. The birthday bound makes this unlikely at hundreds of products, but it is a silent correctness failure that leaks one company's paid content to another company's buyers.","line":25,"path":"apps/engine/src/products.ts","reproduction":"State: company A has product `p` with id `1a2b3c4d`, packaged. Input: company B's shift creates a product whose `randomUUID().slice(0, 8)` is also `1a2b3c4d` (reproduce by stubbing randomUUID or by editing state.json). Expected: B's product gets its own file and its own signed links. Actual: `files.has(B.p)` is true immediately, B's product is listed as for sale, a buyer of B's product downloads `products/1a2b3c4d.md` which is A's deliverable, and A's buyer link also opens under B's slug. Fix: generate ids with more entropy (full UUID or 16+ hex chars) and reject collisions across all companies at creation; key the file path and the HMAC on `${companyId}/${productId}`.","severity":"low","snippet":"  #file(p: Product) { return join(this.#dir, `${p.id.replace(/[^a-zA-Z0-9-]/g, \"\")}.md`); }","title":"Product ids are 8 hex characters with no uniqueness check, and both the packaged file path and the download signature key on the id alone"},{"citation":"resolved","description":"Every owner limit (`dailyBudgetUsd`, `approvalOverUsd`) is enforced by `canSpend` in USD, but the IMD cost is valued at `config.imdUsd`, which `#refreshImdPrice` overwrites every 10 minutes from `api.dexscreener.com` (pair sorted by reported liquidity), falling back to GeckoTerminal. The only sanity check is the 0.01..10000 range. A wrong or manipulated feed (a stale pair, a bad `priceUsd` string, a new high-liquidity pair showing a dumped price, or a compromised/upstream-substituted response) that reports e.g. $0.02 lets a company with a $10 daily budget and $5 ask-over spend 500 IMD (about $3,400 at the pinned default of $6.87) in a day without ever asking the owner, since 0.5 IMD then 'costs' $0.01. The owner's stated limit is therefore not a hard limit on IMD leaving the wallet.","line":592,"path":"apps/engine/src/engine.ts","reproduction":"State: live company with policy {dailyBudgetUsd: 10, approvalOverUsd: 5}, wallet holding 50 IMD. Input: the DexScreener response for IMD lists a pair with `liquidity.usd` above the real pool and `priceUsd: \"0.02\"` (or the real pool briefly prints that). Expected: the company still cannot spend more than about 1.5 IMD (= $10) in a day and asks before anything over $5. Actual: after the next refresh `config.imdUsd = 0.02`; each 0.5 IMD order is valued at $0.01, `canSpend` allows up to 500 IMD/day with no approval request, and the wallet is drained by the next shifts. Fix: enforce the budget in IMD as well (store the IMD price used at policy time and cap daily IMD spend at `dailyBudgetUsd / max(priceAtPolicy, livePrice) * tolerance`), reject feed values that move more than, say, 30% from the last kept value without a second source agreeing, and surface the price actually used in the approval card.","severity":"low","snippet":"    const keep = (price: number) => { if (price > 0.01 && price < 10_000) { config.imdUsd = price; config.imdUsdAt = Date.now(); return true; } return false; };","title":"Spending budgets are converted with an unauthenticated DexScreener price bounded only to [0.01, 10000] USD per IMD"}],"hash":"5e2318655405afa1d904dba24c7d41229086104d2b027fe77e5f8e3a256dfd52","nodeId":"10bb73ba-a3a5-455a-bb40-9295bc7c651a","outcome":"completed","summary":"The review is complete. All seven findings are in `.imd-findings.json` at the repository root, every snippet verified against the tree, and no source file was changed (git status is clean apart from the findings file).\n\n**What I reviewed.** The whole engine (server routes, wallet sign-in and HMAC sessions, the AES-GCM vault, withdrawals and close-out, the x402 buy path and signed download links, the admin token and setup routes, the keeper, Telegram command and moderation plumbing, the MCP server and API keys, Space Pinball matchmaking and server replay), the core spending policy, the IMD client's payment guards, the x402 seller, and every browser transaction builder in apps/web (swap through the Universal Router, staking, frog claims, funding, the browser x402 signer), plus the pinned addresses, both service workers and the CSP. The repository contains no Solidity, so no Foundry proofs apply.\n\n**Findings, by severity.**\n\n- **High, vault.ts.** The wallet vault is rewritten in place with a plain truncating write on every company creation and Bluesky connect. A crash, OOM kill or full disk mid-write empties the only copy of every company private key, and the funded wallets' IMD, ETH and USDC become unrecoverable. Fix: temp file plus rename, as store.ts already does.\n- **Medium, server.ts buy route.** If the receipt wait fails after the settlement was broadcast, the buyer's USDC still moves on chain but no sale is recorded and no download is returned. A retry with the same signature reverts, so the buyer pays twice. Fix: check the EIP-3009 authorization state and treat a consumed nonce as paid.\n- **Medium, frogpot.ts.** An epoch build with an empty pot still resets every holder's accrued shift hours, so a week without a Distributed event silently forfeits those rewards.\n- **Low, net.ts.** DNS is checked once and fetch resolves again, so a rebinding host reaches internal services from swarm-delivered content (blind SSRF).\n- **Low, pinball.ts.** Replay verification only bounds the last tick to wall-clock, so a client running the shipped deterministic engine with lookahead produces unbeatable \"verified\" scores.\n- **Low, products.ts.** Product ids are 32-bit and never checked unique, while the file path and download HMAC key on the id alone, so a collision serves one company's paid file as another's.\n- **Low, engine.ts.** Budgets are enforced in USD at an unauthenticated DexScreener price bounded only to the range 0.01 to 10000, so a bad feed value lets a company spend far past its owner's limit without asking.\n\n**Things checked and found sound.** Session HMAC with timing-safe compare and revocation, the SIWE message binding to domain and nonce, CSRF handling with SameSite cookies plus an Origin check, the x402 verifier (recipient, amount, validity window, EIP-712 domain) and the browser signer's USDC and amount guards, the IMD client's refusal to sign anything not quoted, static-file path containment, the sandbox CSP on generated pages, pinned contract and router addresses in the web bundle, and the Telegram admin gate by user id.\n\n**Not covered.** Live-chain behaviour of the SHIFT, hook, splitter, rewards and frog contracts is out of scope per SECURITY.md and was not exercised. Denial of service by volume was excluded by the brief.","treeHash":null,"usage":{"cachedInputTokens":2341182,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":40102,"runtime":"claude","turns":53,"wallClockMs":657224}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8109087d965ddf6c","findings":[{"citation":"resolved","description":"vault.json is the only copy of every company wallet key, the keeper key and every stored app password (AES-GCM encrypted). create() (line 42) and putSecret() (line 56) overwrite the file with a single writeFileSync on the final path. Node opens with O_TRUNC before writing, so a crash, OOM kill (docker-compose caps the container), SIGKILL during `docker compose down`, or ENOSPC mid-write leaves vault.json empty or truncated. The constructor then JSON.parse()s it and throws, or loads a partial object missing already funded entries. store.ts writes state.json.tmp and renameSync()s it; vault.ts does not, and there is no backup copy. In live mode a company wallet holds the owner's IMD and ETH on Ethereum plus its USDC revenue on Base; without the key those funds are unrecoverable. Every new company (POST /api/companies -> vault.create) and every Bluesky connect (putSecret) rewrites the whole file, so the window recurs for the life of the deployment. Fix: write to `${file}.tmp`, fsync, then renameSync over vault.json (as store.ts does), keep the previous file as vault.json.bak, and refuse to start when the parse fails instead of continuing with an empty vault.","line":42,"path":"apps/engine/src/vault.ts","reproduction":"State: live mode, vault.json holds entries for a funded company A and `keeper`. Input: a second owner calls POST /api/companies, which reaches vault.create(newId) at line 42, and the process is killed (kill -9 / OOM) or the volume returns ENOSPC after open(O_TRUNC) and before the write completes. Expected: A's key survives and only the new company fails. Actual: vault.json is zero-length or truncated; on restart `new Vault()` throws SyntaxError at line 33 (or loads `{}`), vault.account(A.id) throws `no wallet for <id>`, and A's USDC, IMD and ETH can never be signed for again. Compare store.ts lines 71-73, which use tmp + renameSync for the same reason.","severity":"high","snippet":"    writeFileSync(this.#file, JSON.stringify(this.#entries, null, 2), { mode: 0o600 });","title":"Wallet vault is rewritten in place with writeFileSync: one interrupted write destroys every company private key"},{"citation":"resolved","description":"Engine.#withdraw computes fee = available * platformFeeBps / 10000 (line 917), then sends dev (20%) and treasury (80%) legs first (line 933) and the owner's transfer last (line 934). If the owner transfer throws after the fee legs landed, the catch (lines 937-940) books only the fee legs against co:<id>:usdc and rethrows; nothing records that the fee on this revenue is already paid. The next withdrawal sees available = total - fee and computes a fresh fee on that remainder, so the same revenue pays the platform fee twice, and again on each further failure. Reachable without an attacker: #withdraw makes three Base transactions but never checks the wallet's Base ETH (the /buy path checks >= 2e13 wei before one transaction), so a wallet with gas for two transfers fails exactly on the third; a Circle-blocklisted owner address, an RPC error, or viem's receipt timeout (no explicit timeout is set on the `sales` client) on the third transfer does the same. Related drift in the same block: if a fee leg is mined but waitForTransactionReceipt throws, txs is empty, nothing is booked, and the ledger's withdrawable exceeds the wallet's USDC, so every later withdrawal reverts on the first fee leg. Fix: send the owner's share first and the fee legs after it (the fee destinations are the platform's own addresses and can be retried), or record a per-company `fee-paid` credit when a fee leg lands and net it on the next attempt; also check the Base ETH balance covers three transfers before sending the first, and set an explicit receipt timeout with a reconcile against the wallet's USDC balance on timeout.","line":933,"path":"apps/engine/src/engine.ts","reproduction":"State: live mode, PLATFORM_FEE_BPS=500, PLATFORM_TREASURY set, owner holds no SHIFT/frog, ledger co:X:usdc = 100_000_000 (USD 100), the company wallet holds 100 USDC and enough Base ETH for two ERC-20 transfers only. POST /api/companies/X/withdraw. Attempt 1: fee = 5_000_000; send(dev, 1_000_000) ok; send(treasury, 4_000_000) ok; send(owner, 95_000_000) throws (insufficient funds for gas). Catch posts withdraw-fee: co:X:usdc -5_000_000 and rethrows -> HTTP 400. Owner tops up gas and retries. Attempt 2: available = 95_000_000; fee = 4_750_000; owner receives 90_250_000. Expected: owner receives 95.00 USDC in total and the platform 5.00 (5%). Actual (arithmetic checked with the code's integer formula): owner 90.25, platform 9.75, an effective 975 bps. A second failure on attempt 2 pushes the platform's take to 14.26%.","severity":"medium","snippet":"        for (const [dest, v] of parts) await send(dest, v);","title":"Withdrawal sends the platform fee legs before the owner's share and re-charges the fee on every retry after the owner transfer fails"},{"citation":"resolved","description":"buildEpoch sums the frog half of every Distributed event in (s.block, head] (potSince) and the shift hours earned since the last build (earned/sum). Inside the loop a token is skipped with `continue` when total <= 0n (no Distributed event this window) or sum <= 0 (nobody earned hours), or when every share truncates to 0 (line 57). After the loop lines 62-63 unconditionally set s.hours = now and s.block = head and line 64 saves even when tokens is empty and null is returned. Two symmetric losses follow. (a) Pot without hours: the Distributed logs of that window are never read again, no later epoch total includes them, and ShiftRewards holds the ETH/SHIFT in its frog pot with no root that can pay it out. This is the normal state right after launch: the Monday job runs as soon as l.open && contracts.splitter is set (engine.ts:107-110), independent of frogsOn(), and before the NFT exists or accrueStaking has credited anything stakingBoard() is empty. It also happens when staking.json is lost (loadStaking() silently returns an empty state) while frogpot.json still holds last week's hour counts, and whenever the admin presses Build in /setup twice in a row. (b) Hours without pot: in a quiet week (keeper skipped for dust, keeper out of gas, getLogs returned nothing) every holder's hours for that week are consumed with zero payout and the next pot is shared only by hours earned after the reset. Fix: only assign s.hours = now and s.block = head when an epoch was actually produced for every token that had a pot, or carry the unassigned pot (s.carry[token] += total) and unpaid hours forward into the next epoch, and derive each epoch total from the contract's pot balance minus outstanding reservations so that amounts returned after the 30-day claim window are redistributed too.","line":62,"path":"apps/engine/src/frogpot.ts","reproduction":"Case A: frogpot.json = {epochs:[],hours:{}} (or absent), staking.json has no wallets (NFT not live yet or accrueStaking never ran), splitter emitted Distributed(token=0x0, rewards=1e18, ...) at block B in (startBlock, head]. Call buildEpoch({splitter, token}, startBlock). potSince returns {0x0: 5e17}; sum = 0 so both tokens `continue`; tokens = []; s.block = head is saved; return null. Next Monday buildEpoch scans from head+1: the 0.5 ETH never appears in any epoch total, no leaf references it, claimsOf() never returns it. Expected: the 0.5 ETH is carried into the next epoch that has hours. Case B: frogpot.json {block: N, hours: {}}; staking board has wallet W with 168 hours and V with 0; no Distributed logs in (N, head]. buildEpoch returns null but frogpot.json now has hours {W: 168} and block = head. The following Monday 1 ETH arrives, W has 168 new hours and V has 168 new hours, so W's first 168 hours are never paid. Running POST /api/admin/frogpot/build twice within a minute reproduces case A for any Distributed events between the two calls.","severity":"medium","snippet":"  s.hours = now;","title":"Frog epoch builder advances its block cursor and resets everyone's hours even when it assigns nothing, so a window's frog pot or a week's shift hours are dropped for good"},{"citation":"resolved","description":"The /buy handler submits the buyer's transferWithAuthorization from the company wallet (settlePayment, packages/x402/src/seller.ts:136-146), waits for the receipt, and only then calls recordSale and signs the download link (lines 625-626). Nothing persists the pending settlement before the chain call, and nothing later reconciles incoming USDC against the ledger. If the engine restarts between writeContract and the receipt, or waitForTransactionReceipt rejects (RPC error or 429 while polling, viem's default 180 s timeout during a Base congestion spike, the HTTP request aborted) while the transaction is included afterwards, the buyer's USDC lands in the company wallet but: the catch at the bottom of serve() answers 400 with no deliverable and no PAYMENT-RESPONSE header; recordSale never runs so co:<id>:usdc is not credited and the owner cannot withdraw that revenue (withdrawable() reads only the ledger); the buyer's EIP-3009 nonce is consumed so re-sending the same header reverts on chain (authorizationState(from, nonce) == true) and returns 400 again, and the only way to get the product is to sign and pay a second authorization. The stated purpose at line 592, 'never take money for something that cannot be handed over', is broken. Fix: make settlement idempotent on the authorization. Persist (payer, nonce, value, product, tx hash) before awaiting the receipt and flush it; before and after the send read authorizationState(from, nonce) on USDC, and if the authorization is already consumed with to == c.wallet and value >= price treat it as paid: record the sale once keyed by `${from}:${nonce}` and return the deliverable link; on startup and on a timer complete pending hashes via getTransactionReceipt.","line":624,"path":"apps/engine/src/server.ts","reproduction":"State: live mode, company C with a live packaged product P priced 5 USDC, buyer B with 10 USDC on Base, valid PAYMENT-SIGNATURE. Input: GET /buy/C/P; make SALES_RPC answer eth_sendRawTransaction normally but fail eth_getTransactionReceipt (a proxy that drops the method, a provider that 429s, or kill the process after writeContract returns hash H). H is mined a few seconds later. Expected: either B is not charged, or the response carries H and the download link and co:C:usdc += 5 USDC. Actual: the transaction is mined and B's balance drops by 5 USDC; the server responds 400 'Something went wrong' (or nothing, if restarted); store.json has no sale:H entry; engine.ledger.balance(co:C:usdc) is unchanged while the wallet's USDC balance on Base is +5; retrying with the same header returns 400 `settlement reverted`; a fresh signature charges B another 5 USDC.","severity":"medium","snippet":"        const tx = await run;","title":"x402 sale is recorded only after the receipt arrives: a settled payment that outlives the request is neither delivered nor booked, and an honest retry charges the buyer twice"},{"citation":"resolved","description":"publicUrl() (lines 19-32) resolves the hostname with dns.lookup and rejects private answers, but safeFetch then passes the URL object with the original hostname to fetch() (line 40), and undici performs its own, second DNS resolution for the connection. A hostname whose authoritative server alternates answers with TTL 0 (first a public IP, then 127.0.0.1 / 172.18.x / 169.254.169.254) passes the check on the first lookup and connects to the private address on the second. Only ports 80 and 443 are allowed, which still covers http://169.254.169.254/ (cloud metadata; DEPLOY.md targets a cloud VM), the host's port 80/443 and every neighbour on the Docker network. The URLs reaching safeFetch are third-party data: fetchDelivery() (products.ts:101-113) takes `files[...]` and `repoUrl` from the IMD job result produced by the swarm's agents, and checkLinks() (products.ts:83-95) takes every http(s) URL in LLM and swarm text. fetchDelivery returns the fetched body, which #package writes into the private product file (products.ts:52) that the owner downloads and buyers receive, so the response of the internal request is exfiltrated; checkLinks is a blind GET with side effects. privateIp also omits 192.0.0.0/24 and 198.18.0.0/15 and IPv4-mapped addresses inside brackets. Fix: connect to the address that was vetted. With undici: `new Agent({ connect: { lookup: (h, o, cb) => cb(null, [{ address: vetted, family }]) } })` passed as `dispatcher`, keeping the Host header and SNI from the URL, and re-apply the pinning on every redirect hop; extend privateIp with the missing ranges.","line":40,"path":"apps/engine/src/net.ts","reproduction":"State: a live company whose task result from the swarm is `{ files: { \"guide.md\": \"http://rebind.example.net/latest/meta-data/iam/security-credentials/role\" } }`, where rebind.example.net answers 93.184.216.34 on the first query and 169.254.169.254 on the next (TTL 0). Steps: the 4-second tick calls files.ensure -> fetchDelivery -> safeFetch(u). publicUrl(): lookup returns 93.184.216.34 -> allowed. fetch(url) at line 40 resolves again, gets 169.254.169.254, connects on port 80 and returns the metadata body. Expected: the request is refused or made only to the vetted address 93.184.216.34. Actual: the metadata response is returned, prefixed with the product head and written to data/products/<id>.md, downloadable through /api/companies/:id/products/:pid/download and every signed sale link.","severity":"medium","snippet":"    const r = await fetch(url, { ...init, redirect: \"manual\" });","title":"safeFetch validates the hostname once with dns.lookup, then fetch() resolves it again: DNS rebinding reaches internal services and the response is packaged into a product file"},{"citation":"resolved","description":"llmDecision clamps the model's price only for build_product (planner.ts:142: Math.min(499, Math.max(1, ...))) and the owner's setPrice is bounded to USD 0.50-500 (engine.ts:769), but a reprice decision's top-level priceUsd is applied as-is in doInHouse (line 301). The only check is truthiness at line 299. Any positive value above 5e-7 survives usdc() (types.ts:104, BigInt(Math.round(x*1e6))), so 0.000001 makes priceUsdc = 1n and the x402 requirement amount '1': every buyer gets the product for one micro-USDC. A negative value makes priceUsdc negative: requirement() emits a negative amount string, verifyPayment's `BigInt(a.value) < BigInt(req.amount)` passes for a value of 0, the balance check `bal < value` passes with 0, settlePayment submits a 0-value transferWithAuthorization (the company pays gas), and recordSale throws inside ledger validate (zero posting) after p.sales was already incremented. A non-finite value (priceUsd 1e400 from JSON.parse) makes BigInt() throw and aborts the shift. Reachable in normal operation: the system prompt tells the model reprice is a free move, and the model is fed back product names and pitches it generated. Fix: clamp d.priceUsd in llmDecision the same way as d.product.priceUsd (and reject non-finite), have doInHouse refuse priceUsd outside [0.5, 500] as setPrice does, and make requirement() reject amount <= 0.","line":301,"path":"packages/core/src/shift.ts","reproduction":"Input: the CEO model returns {\"kind\":\"reprice\",\"title\":\"x\",\"why\":\"y\",\"productId\":\"<live product id>\",\"priceUsd\":0.000001}. runShift -> doInHouse: usdc(0.000001) = 1n; p.priceUsdc = 1n; the journal says 'price $9.00 -> $0.00'. GET /buy/<slug>/<id> now answers 402 with accepts[0].amount = \"1\". A buyer signs an EIP-3009 authorization for value 1: verifyPayment ok, settlement transfers 1 micro-USDC, recordSale posts 1n, deliverable link returned. Expected: a reprice is held to the same bounds as every other price (>= USD 0.50). Actual: the product is given away for 1 micro-USDC; with priceUsd -5 the amount is \"-5000000\" and a 0-value authorization passes verification.","severity":"medium","snippet":"      p.priceUsdc = usdc(decision.priceUsd);","title":"CEO reprice has no price floor or ceiling: a product can be repriced to 1 micro-USDC, or to a negative amount that a 0-value authorization satisfies"},{"citation":"resolved","description":"The comment on line 258 states the control: settings that move money or trust are announced so that a leaked admin token cannot act quietly. The `touched` list omits PLATFORM_DEV, which receives 20% of every platform fee in Engine.#withdraw (engine.ts:919-920, 933), and omits TELEGRAM_CHAT_ID, TELEGRAM_BOT_TOKEN, TELEGRAM_ADMIN_IDS and LAUNCH_NOTIFY, which decide where every alert and admin card goes and who may run bot commands. All are in EDITABLE (settings.ts:9-19), saveSetup validates PLATFORM_DEV only with isAddress, and POST /api/admin/restart (line 349) applies them. An attacker holding the admin token can change PLATFORM_DEV and collect the dev share of every withdrawal with no message to the operator, or first point TELEGRAM_CHAT_ID at a chat they control so that the alerts for the announced keys reach only them. Fix: add PLATFORM_DEV, TELEGRAM_CHAT_ID, TELEGRAM_BOT_TOKEN, TELEGRAM_ADMIN_IDS and LAUNCH_NOTIFY to `touched`, send the alert before writeSettings and to the chat configured before the change, and alert on /api/admin/restart as well.","line":259,"path":"apps/engine/src/server.ts","reproduction":"Input: `curl -X POST https://host/api/admin/setup -H 'x-admin-token: <leaked>' -H 'Content-Type: application/json' -d '{\"PLATFORM_DEV\":\"0x1111111111111111111111111111111111111111\"}'` then `curl -X POST https://host/api/admin/restart -H 'x-admin-token: <leaked>'`. Expected: an operator alert naming PLATFORM_DEV, as for PLATFORM_TREASURY. Actual: 200 with the new setup state and no engine.alert call (`touched` is empty because 'PLATFORM_DEV' is not in the list); after the restart, the next owner withdrawal of $100 with PLATFORM_FEE_BPS=500 sends the $1 dev part to the attacker's address and the operator's Telegram is silent. Same for `{\"TELEGRAM_CHAT_ID\":\"<attacker chat>\"}`: no alert, and after restart every later alert, including the ones for the announced keys, goes to the attacker.","severity":"medium","snippet":"          const touched = [\"NIGHTSHIFT_MODE\", \"PLATFORM_TREASURY\", \"PLATFORM_FEE_BPS\", \"ETH_RPC\", \"SALES_RPC\", \"SALES_CHAIN_ID\", \"TOKEN_ADDRESS\", \"NFT_ADDRESS\", \"REQUIRE_AUTH\"].filter((k) => k in patch);","title":"The /setup change alert omits PLATFORM_DEV and the Telegram alert settings, so a leaked admin token can redirect the dev fee or silence the alert channel without the announced warning"},{"citation":"resolved","description":"Sign-in is free (any wallet signs a nonce). Every other write route is wrapped in limited(...), but the /api/keys POST at line 471 is not, and Auth.createKey (auth.ts:95-101) pushes a record into store.apiKeys with no ceiling per owner or in total. store.apiKeys is persisted in the single state.json snapshot that Store.flush() re-serialises in full every 3 seconds whenever anything is dirty (store.ts:61-74), and the engine marks the store dirty on every emit. Auth.owner() (auth.ts:85-90) resolves an nsk_ bearer with `this.keys.find(...)`, so every /mcp request with any nsk_-prefixed token walks the whole array. One wallet can turn a free sign-in into unbounded disk growth and CPU on the server that also holds the company wallets. Fix: wrap the route with `limited(req, \"keys\", 10, 3600_000)`, refuse createKey when the owner already holds a reasonable number of keys (for example 20), and index keys by hash in a Map.","line":471,"path":"apps/engine/src/server.ts","reproduction":"Input: sign in once with any wallet (GET /api/auth/nonce, POST /api/auth/verify), then `for i in $(seq 1 200000); do curl -s -b 'ns_session=<token>' -H 'Content-Type: application/json' -d '{\"label\":\"x\"}' -X POST https://host/api/keys; done`. Expected: a 429 after a few dozen calls, or a 400 once the wallet holds a reasonable number of keys. Actual: every call returns 201; GET /api/keys returns 200,000 entries; data/state.json grows by about 150 bytes per key and is rewritten every 3 seconds; `curl -H 'Authorization: Bearer nsk_zzz' https://host/mcp` scans 200,000 hashes before answering 401.","severity":"low","snippet":"        if (req.method === \"POST\") { const b = await body(req); return json(res, 201, engine.auth.createKey(owner, String(b.label ?? \"MCP\"))); }","title":"POST /api/keys has no rate limit and no per-owner cap: any signed-in wallet grows state.json without bound and makes every nsk_ lookup a linear scan"},{"citation":"resolved","description":"Vault (vault.ts:25-30) writes secret.key next to vault.json when NIGHTSHIFT_SECRET is unset; the AES-GCM layer then protects nothing against anyone who obtains the data volume (backup, snapshot, misconfigured mount), since the key sits beside the ciphertext. The same secret derives the session MAC key (auth.ts:24) and the download-link key (products.ts:22), so possession of the volume also forges owner sessions for every wallet. The code comment in vault.ts calls this 'wrong for production', yet setupState marks it 'warn' at line 57, and readyForLive (line 75) only considers 'block' states, so the dashboard shows a green 'ready for live' with real funds while the vault key is unprotected. The 'auth' and 'url' checks, by contrast, escalate to 'block' in live mode. Fix: `state: process.env.NIGHTSHIFT_SECRET ? \"ok\" : live ? \"block\" : \"warn\"`, and refuse to start in live mode when vault.generatedSecret is true.","line":57,"path":"apps/engine/src/admin.ts","reproduction":"State: container started with NIGHTSHIFT_MODE=live and no NIGHTSHIFT_SECRET (docker-compose.yml and the Dockerfile set neither). Input: `curl -H 'x-admin-token: <token>' https://host/api/admin/setup`. Expected: checks[secret].state == \"block\" and readyForLive == false until NIGHTSHIFT_SECRET is set. Actual: checks[secret].state == \"warn\" and readyForLive == true. Anyone who copies /data can run `scryptSync(readFileSync('secret.key','utf8').trim(), 'nightshift-vault-v1', 32)` and decrypt every entry of vault.json (keeper and all company wallets), and mint `s.<owner>.<exp>.<hmac>` sessions for any owner.","severity":"low","snippet":"    { id: \"secret\", label: \"Wallet vault key\", state: process.env.NIGHTSHIFT_SECRET ? \"ok\" : \"warn\",","title":"Live-readiness treats a generated vault secret as a warning, so /setup reports 'ready for live' while every company key is recoverable from the data volume alone"},{"citation":"resolved","description":"soloStart (lines 188-194) returns the seed immediately and duel view() (line 66) reveals it 400 ms before startedAt. The engine is deterministic and shipped byte-identical to the browser (apps/web/src/pinball/engine.ts). The only anti-cheat in finish() (line 142) and soloFinish() (line 200) is that the largest input tick is not ahead of wall-clock time minus 3 s before the server replays. Nothing ties an input to the moment it was pressed: a client can run replay(seed, candidates) locally thousands of times during the match (beam search a few hundred ticks ahead, committing the best branch only after its wall-clock time has passed), submit the best input list, and the server's own replay confirms it. In duels view() also exposes each player's `solo` score and `finished` flag (line 67) before settle, so the second finisher knows the target. The result is recorded on the persisted boards, and engine.ts:159 and :166 post 'NEW ALL-TIME ... RECORD ... Verified by replay' to the community chat. Severity is low today because STAKES_LIVE is false (line 31); the same code path will arbitrate IMD/SHIFT stakes when the escrow goes live. Fix before enabling stakes: require inputs to be streamed via progress() with a hash of each 1-second input window committed before the window's wall-clock time ends and have finish() verify the submitted inputs against those commitments; hide opponents' interim scores until both have finished; at minimum drop the words 'Verified by replay' from the record posts.","line":142,"path":"apps/engine/src/pinball.ts","reproduction":"Input: `POST /api/pinball/solo {\"name\":\"bot\"}` -> `{id, seed}`. Locally (node, with the shipped engine.ts): `let best=null; for (let n=0;n<20000;n++){ const inputs = random flipper taps at random ticks within 0..elapsed*240; const s=replay(seed,inputs).score; if(!best||s>best.score) best={score:s,inputs}; }`; sleep `maxTick / 240` seconds; `POST /api/pinball/solo/<id>/finish {\"inputs\": best.inputs}`. Expected: a score a human could not reach in one real-time attempt is rejected. Actual: 200 with the search-optimised score; it tops /api/pinball/board and, if it beats the record, a 'verified by replay' record card is posted to Telegram. In a duel, GET /api/pinball/match/<id>?player=<me> after the opponent finished shows their `solo` score before you finish.","severity":"low","snippet":"  if (Date.now() - m.startedAt < (lastTick / 240) * 1000 - 3_000) throw new Error(\"inputs from later than the match has run\");","title":"Space Pinball replay only bounds the last input tick to wall-clock: the seed is known up front, so a client can search for optimal inputs with the shipped engine and the server certifies the result as"},{"citation":"resolved","description":"queue() accepts a data:image/svg+xml;base64 avatar of up to 200,000 characters per player (line 82) and never checks that it decodes to an SVG. view() (lines 65-68) includes `avatar` for both players in every event, and progress() (lines 111-123) calls emit(m) on each POST, which writes the full view to every SSE listener of the match (server.ts:400). The progress route allows 600 POSTs a minute per IP (server.ts:406), so a single player with a maximal avatar makes the server send up to 600 x 200 KB = 120 MB a minute to the opponent and to every spectator stream of that match, from the server's own uplink. Fix: send the avatar once in the first event or as a separate field fetched by id, cap data: avatars at a few KB (an onchain frog SVG is small), and validate that the base64 decodes to an SVG document.","line":82,"path":"apps/engine/src/pinball.ts","reproduction":"Input: `POST /api/pinball/queue {\"name\":\"a\",\"currency\":\"FREE\",\"stake\":0,\"avatar\":\"data:image/svg+xml;base64,\" + \"A\".repeat(199000)}` from one client; a second client queues normally and opens `GET /api/pinball/match/<id>/events`. Then from the first client: `for i in $(seq 1 600); do curl -s -X POST https://host/api/pinball/match/<id>/progress -H 'Content-Type: application/json' -d '{\"player\":\"<pid>\",\"score\":1,\"tick\":'$i'}'; done` within a minute. Expected: progress events are small (score, tick, position, attacks). Actual: each SSE event is about 200 KB; the opponent's stream receives about 120 MB in that minute, sent once per listener.","severity":"low","snippet":"  const face = avatar && (/^\\/games\\/pinball\\/frog-\\d\\.webp$/.test(avatar) || /^data:image\\/svg\\+xml;base64,[A-Za-z0-9+/=]{100,200000}$/.test(avatar)) ? avatar : null;","title":"A 200 KB data: avatar is relayed to every match listener on each progress POST: one player can push about 120 MB a minute through the server to their opponent and spectators"},{"citation":"resolved","description":"The Ledger is a MemoryLedgerStore snapshotted to state.json by the 3-second timer at line 87 (store.ts:61-74). Money-moving operations (recordSale, #withdraw, closeOut) post to the ledger and return without awaiting a flush. A crash in the window between an on-chain transfer and the next flush loses the posting while the chain state is final. For a withdrawal this re-creates the already-paid amount as withdrawable after restart, which then feeds the fee-on-fee path of the withdrawal finding; for a sale it hides revenue from the owner. Fix: await store.flush() right after every ledger post that mirrors a chain transfer, or append ledger postings to a write-ahead file synchronously.","line":87,"path":"apps/engine/src/engine.ts","reproduction":"State: live mode, co:X:usdc = 50 USDC, owner withdraws all: the USDC transfer to the owner lands on Base, ledger.post(withdraw:...) runs, then the process is killed within 3 s before flush(). Restart: state.json has no withdraw entry, GET /api/companies/X returns withdrawableUsd 50 although the wallet holds 0 USDC. Expected: 0 withdrawable. The next withdraw attempt sends the fee legs first and reverts on insufficient balance, or, if a new sale brought USDC in, pays the platform fee on the phantom 50.","severity":"low","snippet":"    setInterval(() => void this.store.flush(), 3000);","title":"The ledger that decides withdrawable revenue lives in memory and is flushed only every 3 seconds, so a crash after an on-chain transfer re-creates already-paid revenue"},{"citation":"resolved","description":"buy() posts `imd:<orderId>` debiting co:<id>:imd by caps.price and sets costImd before IMD confirms admission (lines 166-184). When advance() later finds the order in payment_failed or expired (line 232: IMD did not take the IMD, for example the Permit2 transfer failed or the quote lapsed), it marks the task failed but neither zeroes costImd nor reverses the ledger posting. #recover (engine.ts:795) only repairs the `rejected by spendControls` case. Effects: spentTodayUsd (policy.ts:19-23) over-counts, so the company rests on budget for work it never bought; the owner dashboard's spent/profit and imdBalance misreport. Fix: on payment_failed/expired set costImd = 0n and post a reversing ledger entry (`imd-reversal:<orderId>`); on refused keep the charge only if txHash is set.","line":232,"path":"packages/core/src/shift.ts","reproduction":"State: live company, policy dailyBudgetUsd 10, IMD at $6.87. Shift orders a 0.5 IMD job; open() submits; IMD later reports status payment_failed with no transactionHash. Expected: task failed, costImd 0, no IMD debit, budget untouched. Actual: task.costImd = 5e17, ledger co:X:imd -0.5 IMD, spentTodayUsd = 3.43 USD; two such failures plus one real order leave canSpend() returning wait:'budget' for the rest of the day though only 0.5 IMD left the wallet.","severity":"low","snippet":"        if (o.state === \"refused\" || o.state === \"payment_failed\" || o.state === \"expired\") {","title":"Order failures after payment submission keep the IMD cost in the budget and the books even when IMD never charged"},{"citation":"resolved","description":"spentTodayUsd (policy.ts:19-23) sums costImd over c.tasks with updatedAt in the last 24h, and canSpend/budgetFreesAt trust that sum. runShift pushes a task for every move, free or paid, and line 99 trims the array to the newest 300 before buying. Free moves (marketing, reprice, retire_product) are not rate-limited by the saver, do not set restUntil, and a company may run a shift every minute (shiftMinutes >= 1 in validatePolicy). Once 300 free tasks have been appended after the day's paid ones, the paid tasks fall out of the array, spentTodayUsd returns 0, and the next paid order is allowed although the daily budget was already spent. Preconditions: the LLM CEO keeps choosing free moves (the playbook planner spaces marketing 20h apart, so this needs the model path) and shiftMinutes small. Fix: keep spend accounting separate from the task list (c.spend: {at, imd}[] pruned by age, not count), or never evict tasks younger than 24h with costImd > 0.","line":99,"path":"packages/core/src/shift.ts","reproduction":"State: dailyBudgetUsd 10, imdUsd 6.87, job price 0.5 IMD (USD 3.44), shiftMinutes 1, autopilot on, LLM CEO. 00:00 two paid orders land (spent USD 6.87). From 00:02 the model answers reprice/marketing every shift (free). After 300 free shifts (about 5 hours) c.tasks holds only those 300; the two paid tasks are gone. 05:02 the model picks research: spentTodayUsd = 0, canSpend allows, the company buys a third and a fourth 0.5 IMD job the same day (USD 13.74 > USD 10). Expected: at most USD 10 of IMD per rolling 24h. Actual: the budget resets every 300 shifts.","severity":"low","snippet":"  if (c.tasks.length > 300) c.tasks.splice(0, c.tasks.length - 300);","title":"Daily budget window is computed from a task list capped at 300 entries, so free moves can evict paid spend from the last 24h and reopen the budget"},{"citation":"resolved","description":"packages/core/src/shift.ts:117 creates product ids as randomUUID().slice(0, 8) (32 bits) and never checks them against existing products of other companies. ProductFiles.#file (line 25) stores the private deliverable as <dataDir>/products/<id>.md, has()/read() look it up by id only, and #sig() (line 41) signs `${id}|${exp}|${ref}` without the company. When two products in different companies share an id: ensure() for the second returns early because has(p) is already true, so the second company sells the first company's file; a download link bought for one is valid at /download/<otherSlug>/<id>/<exp>/<sig> for the other; and market() lists the second product as packaged although its own delivery was never fetched. The birthday bound makes this unlikely at hundreds of products, but it is a silent correctness failure that leaks one company's paid content to another company's buyers. Fix: use the full UUID (or 16+ hex chars) and reject collisions across all companies at creation; key the file path and the HMAC on `${companyId}/${productId}`.","line":25,"path":"apps/engine/src/products.ts","reproduction":"State: company A has product `p` with id 1a2b3c4d, packaged. Input: company B's shift creates a product whose randomUUID().slice(0, 8) is also 1a2b3c4d (reproduce by stubbing randomUUID or by editing state.json). Expected: B's product gets its own file and its own signed links. Actual: files.has(B.p) is true immediately, B's product is listed as for sale, a buyer of B's product downloads products/1a2b3c4d.md which is A's deliverable, and A's buyer link also opens under B's slug.","severity":"low","snippet":"  #file(p: Product) { return join(this.#dir, `${p.id.replace(/[^a-zA-Z0-9-]/g, \"\")}.md`); }","title":"Product ids are 8 hex characters with no uniqueness check, and both the packaged file path and the download signature key on the id alone"},{"citation":"resolved","description":"Every owner limit (dailyBudgetUsd, approvalOverUsd) is enforced by canSpend in USD, but the IMD cost is valued at config.imdUsd, which #refreshImdPrice overwrites every 10 minutes from api.dexscreener.com (pair sorted by reported liquidity), falling back to GeckoTerminal. The only sanity check is the 0.01..10000 range at line 592. A wrong or manipulated feed (a stale pair, a bad priceUsd string, a new high-liquidity pair showing a dumped price, or a substituted upstream response) that reports e.g. $0.02 lets a company with a $10 daily budget and $5 ask-over spend 500 IMD (about $3,400 at the pinned default of $6.87) in a day without ever asking the owner, since 0.5 IMD then 'costs' $0.01. Fix: enforce the budget in IMD as well (store the price used at policy time and cap daily IMD spend at dailyBudgetUsd / max(priceAtPolicy, livePrice)), reject feed values that move more than about 30% from the last kept value without a second source agreeing, and show the price actually used in the approval card.","line":592,"path":"apps/engine/src/engine.ts","reproduction":"State: live company with policy {dailyBudgetUsd: 10, approvalOverUsd: 5}, wallet holding 50 IMD. Input: the DexScreener response for IMD lists a pair with liquidity.usd above the real pool and priceUsd \"0.02\" (or the real pool briefly prints that). Expected: the company still cannot spend more than about 1.5 IMD (= $10) in a day and asks before anything over $5. Actual: after the next refresh config.imdUsd = 0.02; each 0.5 IMD order is valued at $0.01, canSpend allows up to 500 IMD/day with no approval request, and the wallet is drained by the next shifts.","severity":"low","snippet":"    const keep = (price: number) => { if (price > 0.01 && price < 10_000) { config.imdUsd = price; config.imdUsdAt = Date.now(); return true; } return false; };","title":"Spending budgets are converted with an unauthenticated DexScreener price bounded only to [0.01, 10000] USD per IMD, so a wrong feed value lets a company spend far past the owner's USD limit"},{"citation":"resolved","description":"The `.catch(() => bal(ETH))` at line 107 is meant for a splitter without a reserve(token) view, but it also fires on a transient RPC error. In that case `reserve` becomes the splitter's entire ETH balance, which after a skipped-for-dust distribute (balance below 0.005 ETH, line 102) or fees that arrived between distribute and this read includes ETH that belongs to rewards, dev and treasury. burnReserve(ETH, amountIn, minOut) is then simulated and sent with that amountIn. Whether anything is lost depends on the splitter contract (not in this repository) enforcing amountIn <= reserve; if it does, the run just logs a revert, if it does not, undistributed ETH is bought as IMD and burned. Fix: distinguish 'function missing' (revert data / ABI decoding error) from transport errors, and only fall back on the former; otherwise skip the burn for this run.","line":107,"path":"apps/engine/src/keeper.ts","reproduction":"Keeper run where distribute(ETH) is skipped (splitter holds 0.004 ETH < DUST.eth) and the reserve() RPC call rejects with an HTTP/timeout error. Expected: 'burn: reserve unknown' and no burn. Actual: reserve = 0.004 ETH (the whole balance), quoted via the Quoter, and burnReserve(ETH, 4e15, minOut) is submitted; the outcome depends on the splitter's internal check.","severity":"low","snippet":"  const reserve = await eth.readContract({ address: splitter, abi: SPLITTER, functionName: \"reserve\", args: [ETH] }).catch(() => bal(ETH));","title":"Keeper falls back to the splitter's whole ETH balance as the burn amount whenever the reserve() read fails for any reason, including a transient RPC error"},{"citation":"resolved","description":"verifyShift is the gate before the treasury makes the first buy and before the engine, bot and site switch to the deployed token. Its 'no mint, pause, blacklist or fee switch' check (line 39-40) only searches the runtime bytecode for the five exact 4-byte selectors in FORBIDDEN. A token exposing the same powers under another signature (mintTo(address,uint256) 0x449a52f8, mint(address,uint256,bytes), setTaxes(uint256,uint256), setBlacklisted(address,bool), freeze(address), setMaxTx(uint256)) or an owner-only transfer hook passes with 'none found', and the other token checks (name/symbol/supply/dev balance) do not look at code at all. The splitter and rewards checks likewise accept any contract whose bytecode merely contains the dev, treasury and burn addresses as constants (has()), which a contract can satisfy while routing funds elsewhere. The UI tells the operator 'Everything matches' and shows the first-buy button on this basis. Fix: verify against the source IMD publishes for the launch (compile and compare runtime bytecode, or compare the full selector set against the spec's allowed interface), and for the splitter simulate distribute() with a state override and check the four recipient balances move by 40/20/20/20.","line":17,"path":"apps/engine/src/shift-verify.ts","reproduction":"Deploy (or model) an ERC-20 named NIGHTSHIFT/SHIFT, 1e27 supply, 18 decimals, with `function mintTo(address,uint256) onlyOwner` and `function setTaxes(uint256,uint256) onlyOwner`, and point launchState().contracts.token at it. Run POST /api/admin/token/verify. Expected: 'Token has no mint, pause, blacklist or fee switch' fails. Actual: it reports ok with detail 'none found' because neither 0x449a52f8 nor the setTaxes selector is in FORBIDDEN; with the other checks green the launch card says 'Everything matches' and the first-buy button is shown.","severity":"low","snippet":"const FORBIDDEN: Record<string, string> = { \"40c10f19\": \"mint(address,uint256)\", \"a0712d68\": \"mint(uint256)\", \"8456cb59\": \"pause()\", \"f9f92be4\": \"blacklist(address)\", \"69fe0e2d\": \"setFee(uint256)\" };","title":"Post-deploy token check is a five-selector denylist and passes a token with any other privileged function"},{"citation":"resolved","description":"The first sell approves maxUint256 SHIFT to Permit2 (line 91, standard) and then at line 97 a Permit2 allowance of maxUint160 to the router with expiration 2^48-1. Uniswap's own interface uses a 30-day expiration for the router allowance; the point of Permit2 expirations is to bound exposure if a router is ever compromised or deprecated. Users who sell once through the site keep an unbounded, permanent standing permission for the router to pull all their SHIFT. Fix: use `amt` or a 30-day expiration (`Math.floor(Date.now()/1000) + 30*86400`), as Uniswap does, re-approving when expired.","line":97,"path":"apps/web/src/SwapPage.tsx","reproduction":"Connect a wallet holding SHIFT, choose Sell, enter any amount, confirm the two approvals. Read Permit2.allowance(user, SHIFT, V4.router): expected a bounded amount or a 30-day expiration; actual (2^160-1, 281474976710655).","severity":"low","snippet":"          const h = await w.writeContract({ account: a, chain: mainnet, address: V4.permit2, abi: permit2Abi, functionName: \"approve\", args: [token, V4.router, maxUint160, 2 ** 48 - 1] });","title":"Swap sell path grants the Universal Router a maximum, never-expiring Permit2 allowance"},{"citation":"resolved","description":"ethPerImd() fetches an IMD/ETH price from DexScreener but is never called; the burn's minimum comes from the on-chain Quoter (line 110). Dead code that reads an external price API is a maintenance trap: a future edit could wire an off-chain price into minOut. Remove it, or use it only as a sanity bound against the Quoter.","line":43,"path":"apps/engine/src/keeper.ts","reproduction":"`grep -n ethPerImd apps/engine/src/*.ts` shows only the definition at keeper.ts:43 and no call site.","severity":"info","snippet":"async function ethPerImd(): Promise<number> {","title":"Unused DexScreener price helper in the keeper reads an off-chain, manipulable price"}],"hash":"7dfe802ea7f629c482f55150161ae960d385f5a22222ea92b1a4c20c66b4936e","nodeId":"4f865482-b0c3-40ea-8312-0c68a796981d","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 20 verified findings, every snippet matches the tree, and no other file was touched.\n\n**What I did**\n\n- Read every file the 27 specialist findings point at (vault, engine withdraw and price refresh, frogpot, server buy/keys/setup/pinball routes, net, products, auth, admin, settings, core shift and policy, planner, x402 seller, keeper, shift-verify, SwapPage) and traced each claim through the code.\n- Checked the fee-on-fee arithmetic with the code's own integer formula, confirmed the dead helper has no call site, and confirmed that the vault alone writes in place while the store uses tmp plus rename.\n- No Solidity exists in the repository, so no Foundry proofs apply. The repo has no node_modules and the dependency set includes a GitHub-pinned package, so I did not spend the budget installing it. Reproductions are stated as concrete inputs and states against the code.\n\n**Result**\n\n- All 27 specialist findings reproduced. None were dropped.\n- Merged into 20: the withdrawal fee-on-fee (2 reports), the frog epoch cursor and hours reset (3 reports, both loss modes kept in one finding), the x402 settlement gap (2 reports), the DNS-rebinding SSRF (2 reports), and the pinball replay cheat (3 reports).\n- Severities: 1 high (vault rewritten in place, one interrupted write loses every company key), 6 medium (fee re-charged on retry, frog pot dropped, settled payment not booked or delivered, SSRF into the product file, unbounded CEO reprice, setup alert missing PLATFORM_DEV and Telegram keys), 12 low, 1 info.\n- Two downgrades from the specialists: the unlimited POST /api/keys and the generated-secret readiness check are low rather than medium, since both need either an operator misconfiguration or a long self-inflicted flood to matter.\n\n**Own pass**\n\nThe owner-gated company routes, the MCP withdraw target, and the CSP looked sound. Live withdrawals go only to the signed-in owner, and the static file server resolves paths inside the web dist.","treeHash":null,"usage":{"cachedInputTokens":1073933,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":27498,"runtime":"claude","turns":32,"wallClockMs":354724}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa8fc4653a9e883d","findings":[{"citation":"resolved","description":"Engine.#withdraw sends the fee parts (dev, treasury) first and the owner's share last. If the owner transfer fails after the fee transfers landed, the catch block books only the fee that left (ledger id withdraw-fee:<tx>) and rethrows. Nothing records that this company has already paid the fee on that revenue. On the next withdrawal the fee is recomputed as bps x the whole remaining balance, so the same revenue is charged twice; every further failure compounds it. The invariant 'platform fee = platformFeeBps of revenue' holds only on the happy path (seam: boundary x invariant). The failure is reachable without an attacker: #withdraw performs three Base transactions but never checks the wallet's Base ETH (the x402 path checks >= 2e13 wei before one transaction), so a wallet with gas for two transfers but not three fails exactly on the owner's transfer; an RPC error or receipt timeout on the third transfer does the same. A related drift: if a fee transfer is mined but waitForTransactionReceipt throws, txs is empty, nothing is booked, and the ledger's withdrawable exceeds the on-chain USDC. Fix: track fee already taken per company (e.g. a 'feePrepaid' balance or an ext:platform posting keyed to the company) and net it on the next attempt, or send the owner's share first and the fee parts last, or compute fee on (total - feeAlreadyBooked). Also check the Base ETH balance covers three transfers before sending the first.","line":917,"path":"apps/engine/src/engine.ts","reproduction":"State: PLATFORM_FEE_BPS=500, live mode, owner not a SHIFT/frog holder, ledger co:<id>:usdc = 100_000_000 (USD 100.00), company wallet on Base holds USDC 100 and ETH enough for two ERC-20 transfers only. POST /api/companies/<id>/withdraw. Attempt 1: fee = 100_000_000*500/10_000 = 5_000_000; devFee = 1_000_000 sent OK; treasury 4_000_000 sent OK; owner transfer of 95_000_000 throws (insufficient funds for gas). Catch books -5_000_000 and rethrows -> HTTP 400. Owner tops up gas and retries. Attempt 2: available = 95_000_000; fee = 95_000_000*500/10_000 = 4_750_000; sent = 90_250_000. Expected: owner receives 95.00 USDC in total and the platform 5.00 (5%). Actual: owner receives 90.25, platform takes 9.75 (9.75%). A second failure on attempt 2 would push it to 14.26%.","severity":"medium","snippet":"    const fee = config.platformTreasury && !holder ? (total * BigInt(config.platformFeeBps)) / 10_000n : 0n;","title":"Platform fee is charged again on every retried withdrawal after a partial failure (owner loses fee-on-fee)"},{"citation":"resolved","description":"llmDecision clamps the model's price only for build_product (planner.ts:142: Math.min(499, Math.max(1, ...))) and the owner's setPrice is bounded to USD 0.50-500 (engine.ts:769), but the reprice decision's top-level priceUsd is applied as-is in doInHouse. The only check is truthiness. Any positive value above 5e-7 survives usdc() (BigInt(Math.round(x*1e6))), so a model answer of 0.000001 makes priceUsdc = 1n and the x402 requirement amount '1': every buyer gets the product for one micro-USDC. A negative value makes priceUsdc negative; requirement() emits a negative amount string, verifyPayment's 'BigInt(a.value) < BigInt(req.amount)' passes for a value of 0, settlePayment submits a 0-value transferWithAuthorization (company pays gas), and recordSale then throws inside ledger.validate on the zero posting after p.sales was already incremented. A NaN/Infinity (e.g. priceUsd 1e400 from JSON.parse) makes BigInt() throw and aborts the shift. Reachable in normal operation: the model is instructed that reprice is a free move and is retried every shift; a hallucinated or prompt-steered number is enough (product names and pitches it generated are fed back to it). Fix: clamp d.priceUsd in llmDecision the same way as d.product.priceUsd (and reject non-finite), and have doInHouse refuse priceUsd outside [0.5, 500] as setPrice does; optionally make requirement() reject amount <= 0.","line":301,"path":"packages/core/src/shift.ts","reproduction":"Input: the CEO model returns {\"kind\":\"reprice\",\"title\":\"x\",\"why\":\"y\",\"productId\":\"<live product id>\",\"priceUsd\":0.000001}. runShift -> doInHouse: usdc(0.000001) = BigInt(Math.round(1)) = 1n; p.priceUsdc = 1n; journal says 'price $9.00 -> $0.00'. GET /buy/<slug>/<id> now answers 402 with accepts[0].amount = \"1\". A buyer signs an EIP-3009 authorization for value 1 (0.000001 USDC): verifyPayment ok, settlement transfers 1 micro-USDC, recordSale posts 1n, deliverable link returned. Expected: a reprice is held to the same bounds as every other price (>= USD 0.50 or >= 1). Actual: the product is given away for 1 micro-USDC; with priceUsd -5 the amount is \"-5000000\" and a 0-value authorization passes verification.","severity":"medium","snippet":"      p.priceUsdc = usdc(decision.priceUsd);","title":"CEO reprice has no price floor or ceiling: a product can be repriced to 1 micro-USDC (or negative) and sold for nothing"},{"citation":"resolved","description":"buildEpoch reads every Distributed event since s.block, takes rewards/2 per token as the frog pot, and shares it by the shift hours earned since the last epoch. When sum (hours earned by all wallets) is 0, or every share truncates to 0 and values is empty, the token is skipped with continue. The function then unconditionally sets s.block = head and saves, so those Distributed logs are never read again and no later epoch includes that pot. The invariant 'every rewards/2 that reached the rewards contract is eventually put in a Merkle root' breaks at the zero-input fast path (seam: boundary x invariant). sum is 0 in reachable states: the first weekly build after launch when accrueStaking has credited nothing yet (its first run credits 0 hours because staking.at was 0), NFT_ADDRESS not yet set (frogsOn() false -> staking never accrues) while the token already distributes, or staking.json lost or unparsable (loadStaking() silently returns an empty state) while frogpot.json still holds last week's higher hour counts, so every Math.max(0, h - old) is 0. Fix: only advance s.block (and s.hours) when an epoch was actually produced for every token that had a pot, or carry the unassigned pot per token forward (s.carry[token] += total) into the next epoch.","line":54,"path":"apps/engine/src/frogpot.ts","reproduction":"State: launch open, splitter has emitted Distributed(token=ETH, rewards=1 ETH, ...) once since s.block; staking.json was reset (empty wallets) while frogpot.json has hours {0xabc: 500}. Monday 00:00 UTC -> engine.frogEpoch() -> buildEpoch. potSince -> pot[ETH] = 0.5 ETH. now = {} so earned = [], sum = 0. Loop: total = 5e17 > 0 but sum <= 0 -> continue for ETH and SHIFT. tokens = [] -> s.hours = {}, s.block = head, save, return null. Next Monday potSince starts at head+1: the 0.5 ETH never appears in any epoch, no root is ever posted for it, and no holder can claim it. Expected: the 0.5 ETH is included in the next epoch that has hours. Actual: it is permanently unassigned.","severity":"medium","snippet":"    if (total <= 0n || sum <= 0) continue;","title":"Frog epoch builder advances its block cursor even when it assigns nothing, so a window's frog pot is dropped for good"},{"citation":"resolved","description":"spentTodayUsd (policy.ts:19) sums costImd over c.tasks with updatedAt in the last 24h, and canSpend/budgetFreesAt trust that sum as 'spent today'. runShift pushes a task for every move, free or paid, and trims the array to the newest 300 before buying. Free moves (marketing, reprice, retire_product) are not rate-limited by the saver, do not set restUntil, and a company may run a shift every minute (shiftMinutes >= 1, validated in validatePolicy). Once 300 free tasks have been appended after the day's paid ones, the paid tasks fall out of the array, spentTodayUsd returns 0, and the next paid order is allowed although the daily budget was already spent. The cap (boundary) and the budget invariant interact: each is fine alone. Preconditions: the LLM CEO keeps choosing free moves (the playbook planner spaces marketing 20h apart, so this needs the model path), and shiftMinutes small. Fix: keep the spend accounting separate from the task list (e.g. c.spend: {at, imd}[] pruned by age, not by count), or never evict tasks younger than 24h with costImd > 0.","line":99,"path":"packages/core/src/shift.ts","reproduction":"State: dailyBudgetUsd 10, imdUsd 6.87, job price 0.5 IMD (USD 3.44), shiftMinutes 1, autopilot on, LLM CEO. 00:00 two paid orders land (spent USD 6.87). From 00:02 the model answers reprice/marketing every shift (free, no restUntil). After 300 free shifts (about 5 hours) c.tasks holds only those 300; the two paid tasks are gone. 05:02 the model picks research: spentTodayUsd = 0, canSpend allows, the company buys a third and a fourth 0.5 IMD job the same day (USD 13.74 > USD 10). Expected: at most USD 10 of IMD per rolling 24h. Actual: budget resets every 300 shifts.","severity":"low","snippet":"  if (c.tasks.length > 300) c.tasks.splice(0, c.tasks.length - 300);","title":"Daily budget window is computed from a list capped at 300 tasks, so free moves can evict paid spend from the last 24h and reopen the budget"}],"hash":"89d32ea43f2cfe4fc26b00d4b8bde631ef3557fe6f06daa51d05f159b9032b42","nodeId":"8d1dbcbe-66b2-47f3-8e5a-8004a1999a45","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Outcome.** The repository contains no Solidity, so the math review ran against the TypeScript money paths: the spending policy and shift loop, the ledger, the x402 seller, withdrawals and close-out, the keeper, the frog pot epochs, the pinball replay, and every browser transaction in the swap, stake, claim and fund pages. Four defects were substantiated and written to `.imd-findings.json`, each with its exact source line verified against the tree and a concrete failing state.\n\n**Findings, by severity.**\n- **Medium, double platform fee on retried withdrawals** at `apps/engine/src/engine.ts:917`. Fee parts leave first and get booked when the owner transfer fails, but nothing remembers the fee was paid. The retry charges bps on the remainder again. With a 5% fee on 100 USDC the owner nets 90.25 instead of 95.00. The withdraw path also never checks Base gas before three transfers, which is one realistic trigger.\n- **Medium, unbounded CEO reprice** at `packages/core/src/shift.ts:301`. Product prices from the model are clamped for new products and for owner edits, but a reprice value is applied raw. A model answer of 0.000001 sets the x402 amount to one micro-USDC and the product sells for nothing. A negative value produces a negative requirement that a zero-value authorization passes.\n- **Medium, frog pot dropped when no hours were earned** at `apps/engine/src/frogpot.ts:54`. The block cursor advances even when a token is skipped, so the half of rewards for that window never enters any Merkle root. Reachable on the first weekly build and whenever the staking state resets while the epoch state keeps old hour counts.\n- **Low, daily budget leaks through the 300-task cap** at `packages/core/src/shift.ts:99`. Free moves push paid tasks out of the array that the 24-hour spend sum reads, so a company on one-minute shifts can reopen its budget after about five hours.\n\n**Checked and found sound.** Slippage and minimum-out math in the swap page and keeper, the frog share rounding (hours are already integers, so shares sum to at most the pot), the x402 amount and expiry checks, the IMD client's hard cap of 0.5 IMD per payment, the policy validator bounds, the download link expiry comparison, the session expiry math, and the pinball finish-time check.\n\n**Not covered in depth.** The vault, admin token handling, the MCP server beyond its withdraw path, the service workers, moderation, the community buy-bot formatting, and the pinball physics engine itself were only skimmed or not reviewed. Nothing in them was confirmed as a defect, and a clean pass over the areas above is not proof that none exist there.","treeHash":null,"usage":{"cachedInputTokens":1604381,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":32678,"runtime":"claude","turns":45,"wallClockMs":539911}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2bdd77125ec75e6c","findings":[{"citation":"resolved","description":"In Engine.#withdraw the fee legs (dev 20%, treasury 80%) are sent on Base first and the owner's transfer last. If the owner transfer throws after the fee legs landed, the catch books only the fee legs against co:<id>:usdc and rethrows; nothing records that the fee for this revenue has already been paid. On the next withdraw the ledger shows `available = total - fee`, and the fee is computed again on that remainder, so the same revenue pays the platform fee twice (and again on every further failure). Invariant broken: total platform fee collected <= platformFeeBps x revenue. Two realistic triggers: (a) the owner address is on Circle's USDC blocklist or the company wallet runs out of Base ETH after the two fee transfers, so `send(to, sent)` reverts or is never mined; (b) viem's waitForTransactionReceipt times out (180 s default on the `sales` client, no explicit timeout set) while the owner tx is later included: then the fee is booked, the owner's amount is not, the wallet is empty and the ledger still shows `total - fee` as withdrawable, so later withdrawals revert on the first fee leg and the phantom balance is permanent. Fix: send the owner's transfer first and the fee legs after it (the fee legs are to addresses we control and can be retried), or when a fee leg has landed post a `fee-paid` credit so the retry computes the fee on the gross only once; add an explicit receipt timeout and on timeout reconcile against the wallet's USDC balance before booking anything.","line":933,"path":"apps/engine/src/engine.ts","reproduction":"State: live mode, PLATFORM_FEE_BPS=1000, PLATFORM_TREASURY set, company with ledger balance co:X:usdc = 100.000000 USDC and 100 USDC in its Base wallet; owner is not a SHIFT/frog holder. Owner calls POST /api/companies/X/withdraw with no amount. Expected: dev gets 2, treasury 8, owner gets 90 exactly once; if the owner leg fails the next attempt still pays the owner 90 with no further fee. Actual: send(dev,2) and send(treasury,8) succeed; send(owner,90) throws (owner blocklisted, or receipt timeout). Ledger posts withdraw-fee: co:X:usdc -10. Second call: available = 90, fee = 9, dev 1.8, treasury 7.2, owner 81. The owner has lost 9 USDC to the treasury relative to the 10% rule, and each further failed retry takes another 10% of what is left. In the timeout variant the wallet holds 0 USDC after the owner tx lands late, the ledger still says 90, and every later withdraw throws `transfer reverted` on the first fee leg.","severity":"medium","snippet":"        for (const [dest, v] of parts) await send(dest, v);\n        await send(to, sent);","title":"Platform fee is sent before the owner's share and is re-charged on every retry when the owner transfer fails"},{"citation":"resolved","description":"buildEpoch sums the frog half of every Distributed event between `s.block+1` and the current head, then skips the token when `sum <= 0` (no wallet earned hours since the previous build) or when every computed share rounds to 0. After the loop it unconditionally advances `s.block = head` and overwrites `s.hours = now` (lines 62-64) even when `tokens` is empty and it returns null. The next build scans from head+1, so the Distributed rewards of that window never enter any Merkle tree. Per the launch spec (shift-launch.ts) ShiftRewards keeps that value in its frog pot and postRoot can only reserve from it, so the ETH/SHIFT is not lost on chain, but nothing in this code ever allocates it: every later epoch total is computed only from the Distributed events of its own window, never from the contract pot. The same root cause means the amounts the spec says return to the pot 30 days after an unclaimed epoch are never redistributed either. Protocol guarantee violated: the frog half of every fee is shared with holders by shift hours. This is the normal state right after launch: the Monday job runs as soon as `l.open && contracts.splitter` is set (engine.ts:107-110), independent of frogsOn(), and before the NFT exists or accrueStaking has run stakingBoard() is empty, so the first weeks of frog rewards sit unallocated. It also happens whenever accrueStaking failed for a week (RPC outage) or the admin presses Build in /setup twice in a row (the second run has sum = 0 and swallows any Distributed events since the first). Fix: when a token pot cannot be assigned (sum <= 0 or no non-zero shares) carry it forward in the state file and add it to the next epoch total, or do not advance `s.block` when `tokens` is empty; and derive each epoch total from the contract frog pot balance (pot minus outstanding reservations) rather than only from new Distributed events so returned 30-day amounts are redistributed.","line":54,"path":"apps/engine/src/frogpot.ts","reproduction":"State: frogpot.json = {epochs:[],hours:{}} (or absent), staking.json has no wallets (NFT not yet live or accrueStaking never ran), splitter emitted Distributed(token=0x0, rewards=1e18, ...) at block B inside (startBlock, head]. Call buildEpoch({splitter, token}, startBlock). Expected: the 0.5 ETH frog half is either assigned to holders or carried to the next epoch total. Actual: potSince returns {0x0: 5e17}; sum = 0 so both tokens `continue`; tokens = []; s.block = head is saved; return null. Next Monday buildEpoch scans from head+1: the 0.5 ETH never appears in any epoch total, no leaf references it, claimsOf() never returns it, and ShiftRewards holds it in the frog pot with no root able to pay it out unless the dev crafts one by hand. Running buildEpoch twice within a minute (admin POST /api/admin/frogpot/build) reproduces the same gap for any Distributed events between the two calls.","severity":"medium","snippet":"    if (total <= 0n || sum <= 0) continue;","title":"Frog pot of an epoch window is never allocated when no wallet earned shift hours in it (and expired claims are never recycled)"},{"citation":"resolved","description":"The /buy handler submits the buyer's transferWithAuthorization from the company wallet, waits for the receipt, and only then records the sale and signs the download link. Nothing persists the pending settlement before the chain call, and nothing later reconciles incoming USDC against the ledger. If the engine restarts between writeContract and the receipt, or waitForTransactionReceipt rejects (viem's default 180 s timeout, RPC error, or the client's 20 s abort upstream) while the transaction is included afterwards, the buyer's USDC lands in the company wallet but: the buyer receives a 400/503 and no deliverable; recordSale never runs so co:<id>:usdc is not credited and the owner cannot withdraw that revenue (withdrawable reads only the ledger); the buyer's EIP-3009 nonce is consumed so re-sending the same payment reverts, and a fresh payment charges them twice. Flow gap between execution (await chain) x periphery (Base inclusion latency, RPC errors) x the stated purpose \"never take money for something that cannot be handed over\". Fix: persist a `pending-settlement` record (payer, nonce, value, product, tx hash) before awaiting the receipt and flush it; on startup and on a timer, check pending hashes (getTransactionReceipt) and complete recordSale + emit the deliverable link (or scan the wallet's incoming USDC Transfer logs) so a late inclusion still produces a sale and a link; return the tx hash to the buyer with a retrievable download endpoint keyed by it.","line":624,"path":"apps/engine/src/server.ts","reproduction":"State: live mode, product P of company C priced 5 USDC, buyer signs a valid authorization. Request GET /buy/C/P with PAYMENT-SIGNATURE. Server runs settlePayment: writeContract returns hash H; then the process is killed (or Base RPC returns an error / the receipt poll exceeds 180 s) before `await run` resolves, and H is mined a few seconds later. Expected: the buyer gets a download link for P and co:C:usdc += 5 USDC. Actual: the response (if any) is an error; store.json has no sale:H entry after restart; engine.ledger.balance(co:C:usdc) is unchanged; GET /api/companies/C shows withdrawableUsd unchanged while the wallet's USDC balance on Base is +5. The buyer's nonce is spent, so retrying with the same header fails with `settlement reverted`; a new signature charges 5 USDC again.","severity":"medium","snippet":"        const tx = await run;\n        await engine.recordSale(c, product.id, BigInt(payment.payload.authorization.value), v.payer, tx, false);","title":"x402 sale is recorded only after the receipt arrives: a settled payment that outlives the request is neither delivered nor booked"},{"citation":"resolved","description":"The Ledger is a MemoryLedgerStore snapshotted to state.json by a 3-second timer (store.ts:61-74). Money-moving operations (recordSale, #withdraw, closeOut) post to the ledger and return without awaiting a flush. A crash in the window between an on-chain transfer and the next flush loses the posting while the chain state is final. For a withdrawal this re-creates the already-paid amount as withdrawable after restart (which then feeds the double-fee path in the first finding); for a sale it hides revenue from the owner. Fix: await store.flush() right after every ledger post that mirrors a chain transfer, or append ledger postings to a write-ahead file synchronously.","line":87,"path":"apps/engine/src/engine.ts","reproduction":"State: live mode, co:X:usdc = 50 USDC, owner withdraws all: USDC transfer to owner lands on Base, ledger.post(withdraw:...) runs, then the process is killed within 3 s before flush(). Restart: state.json has no withdraw entry, GET /api/companies/X returns withdrawableUsd 50 although the wallet holds 0 USDC. Expected: 0 withdrawable. The next withdraw attempt sends the fee legs first and reverts on insufficient balance, or, if a new sale brought USDC in, pays the platform fee on the phantom 50.","severity":"low","snippet":"    setInterval(() => void this.store.flush(), 3000);","title":"Ledger that decides withdrawable revenue lives in memory and is flushed only every 3 seconds"},{"citation":"resolved","description":"buy() posts `imd:<orderId>` debiting co:<id>:imd by caps.price and sets costImd before IMD confirms admission. When advance() later finds the order in payment_failed or expired (IMD did not take the IMD, e.g. the Permit2 transfer failed or the quote lapsed), it marks the task failed but neither zeroes costImd nor reverses the ledger posting. #recover only repairs the `rejected by spendControls` case. Effects: spentTodayUsd over-counts, so the company rests on budget for work it never bought (with a $10 daily budget and 0.5 IMD at $6.87 one phantom order removes a third of the day); the owner dashboard's spentUsd/profitUsd and imdBalance misreport. Not a direct fund loss, but an invariant break between books and chain that can also hide a real double charge. Fix: on payment_failed/expired set costImd = 0n and post a reversing ledger entry (`imd-reversal:<orderId>`); on refused keep the charge only if `txHash` is set.","line":232,"path":"packages/core/src/shift.ts","reproduction":"State: live company, policy dailyBudgetUsd 10. Shift orders a 0.5 IMD job; open() submits; IMD later reports status payment_failed with no transactionHash. Expected: task failed, costImd 0, no IMD debit, budget untouched. Actual: task.costImd = 5e17, ledger co:X:imd -0.5 IMD, spentTodayUsd = 3.43 USD; two such failures plus one real order leave canSpend() returning wait:'budget' for the rest of the day though only 0.5 IMD left the wallet.","severity":"low","snippet":"        if (o.state === \"refused\" || o.state === \"payment_failed\" || o.state === \"expired\") {\n          touch(c, t, d, { status: \"failed\", error: `order ${o.state}${o.problems ? `: ${o.problems.join(\"; \")}` : \"\"}`, activity: null });","title":"Order failures after payment submission keep the IMD cost in budget and books even when IMD never charged"},{"citation":"resolved","description":"Both solo runs and duels reveal the seed to the client before or at start (soloStart returns it; view() returns it 400 ms before startedAt) and the engine is fully deterministic (rng seeded from `seed`, attacks derived from events). The only server check is that the largest input tick is not ahead of wall-clock time. A client can therefore run replay(seed, candidates) locally thousands of times during the match and submit the best-scoring input list; the server's own replay confirms it. In duels the view also exposes each player's `solo` score and `finished` flag (line 67) before settle, so the second finisher knows the target. Today stakes are FREE (STAKES_LIVE=false) so only the leaderboards and community posts are affected, but the same code path will arbitrate IMD/SHIFT stakes when the escrow goes live. Fix before enabling stakes: commit-reveal the seed (server commits to a hash, reveals it only tick-by-tick or after both inputs are in), or require inputs to be streamed incrementally with per-chunk timestamps the server records, and hide opponents' interim scores until both have finished.","line":142,"path":"apps/engine/src/pinball.ts","reproduction":"POST /api/pinball/solo -> {id, seed}. Offline: for N random input lists (ticks within 0..elapsed*240), compute replay(seed, inputs).score with the same engine (apps/engine/src/pinball/engine.ts), keep the best; wait until lastTick/240 s have passed; POST /api/pinball/solo/<id>/finish with that list. Expected: a score a human could not reach is rejected. Actual: it is accepted, recorded on the board and posted to Telegram as 'Verified by replay'. In a duel, GET /api/pinball/match/<id>?player=<me> after the opponent finished shows their `solo` score before you finish.","severity":"low","snippet":"  if (Date.now() - m.startedAt < (lastTick / 240) * 1000 - 3_000) throw new Error(\"inputs from later than the match has run\");","title":"Replay verification cannot stop a scripted client: the seed is known before play and inputs only need to fit the elapsed time"},{"citation":"resolved","description":"The first sell approves maxUint256 SHIFT to Permit2 (standard) and then a Permit2 allowance of maxUint160 to the router with expiration 2^48-1 (year 8.9 million). Uniswap's own interface uses a 30-day expiration for the router allowance; the point of Permit2 expirations is to bound exposure if a router is ever compromised or deprecated. Users who sell once through the site keep an unbounded, permanent standing permission for the router to pull all their SHIFT. Not an exploit of this code, but a weaker safety posture than the referenced Uniswap flow for every seller. Fix: use `amt` or a 30-day expiration (`Math.floor(Date.now()/1000) + 30*86400`), as Uniswap does, re-approving when expired.","line":97,"path":"apps/web/src/SwapPage.tsx","reproduction":"Connect a wallet holding SHIFT, choose Sell, enter any amount, confirm the two approvals. Read Permit2.allowance(user, SHIFT, 0x66a9...8af): expected a bounded amount or a 30-day expiration; actual (2^160-1, 281474976710655).","severity":"low","snippet":"          const h = await w.writeContract({ account: a, chain: mainnet, address: V4.permit2, abi: permit2Abi, functionName: \"approve\", args: [token, V4.router, maxUint160, 2 ** 48 - 1] });","title":"Swap sell path grants the Universal Router a maximum, never-expiring Permit2 allowance"},{"citation":"resolved","description":"verifyShift is the gate before the treasury makes the first buy and before the engine, bot and site switch to the deployed token. Its 'no mint, pause, blacklist or fee switch' check only searches the runtime bytecode for five exact 4-byte selectors. A token exposing the same powers under another signature (mintTo(address,uint256) 0x449a52f8, mint(address,uint256,bytes), setTaxes(uint256,uint256), setBlacklisted(address,bool), freeze(address), setMaxTx(uint256)) or an owner-only transfer hook passes with 'none found', and the other token checks (name/symbol/supply/dev balance) do not look at code at all. The splitter and rewards checks likewise accept any contract whose bytecode merely contains the dev, treasury and burn addresses as constants (has()), which a contract can satisfy while routing funds elsewhere. Since the fixed addresses and the swarm's audit are the real controls, this is a weak second line, but the UI tells the operator 'Everything matches' and 'safe to open' on this basis. Fix: verify against the source IMD publishes for the launch (compile and compare runtime bytecode, or at least compare the full selector set against the spec's allowed interface), and for the splitter simulate distribute() with a state override and check the four recipient balances actually move by 40/20/20/20.","line":17,"path":"apps/engine/src/shift-verify.ts","reproduction":"Deploy (or model) an ERC-20 named 'NIGHTSHIFT'/'SHIFT', 1e27 supply, 18 decimals, with `function mintTo(address,uint256) onlyOwner` and `function setTaxes(uint256,uint256) onlyOwner`, and point launchState().contracts.token at it. Run POST /api/admin/token/verify. Expected: 'Token has no mint, pause, blacklist or fee switch' fails. Actual: it reports ok with detail 'none found' because neither selector 0x449a52f8 nor setTaxes is in FORBIDDEN; with the other checks green the launch card says 'Everything matches' and the first-buy button is shown.","severity":"low","snippet":"const FORBIDDEN: Record<string, string> = { \"40c10f19\": \"mint(address,uint256)\", \"a0712d68\": \"mint(uint256)\", \"8456cb59\": \"pause()\", \"f9f92be4\": \"blacklist(address)\", \"69fe0e2d\": \"setFee(uint256)\" };","title":"Post-deploy token check is a five-selector denylist and can be passed by a token with any other privileged function"},{"citation":"resolved","description":"The fallback is meant for a splitter without a reserve(token) view, but `.catch` also fires on a transient RPC error. In that case `reserve` becomes the splitter's entire ETH balance, which after a skipped-for-dust distribute (balance below 0.005 ETH, line 102) or fees that arrived between distribute and this read includes ETH that belongs to rewards, dev and treasury. burnReserve(ETH, amountIn, minOut) is then simulated and sent with that amountIn. Whether anything is lost depends on the splitter contract (not in this repository) enforcing amountIn <= reserve; if it does, the run just logs a revert, if it does not, undistributed ETH is bought as IMD and burned. Fix: distinguish 'function missing' (revert data / ABI error) from transport errors, and only fall back when the former; otherwise skip the burn for this run.","line":107,"path":"apps/engine/src/keeper.ts","reproduction":"Keeper run where distribute(ETH) is skipped (splitter holds 0.004 ETH < DUST.eth) and the reserve() RPC call rejects with an HTTP/timeout error. Expected: 'burn: reserve unknown' and no burn. Actual: reserve = 0.004 ETH (whole balance), quoted via the Quoter, and burnReserve(ETH, 4e15, minOut) is submitted; outcome depends on the splitter's internal check.","severity":"low","snippet":"  const reserve = await eth.readContract({ address: splitter, abi: SPLITTER, functionName: \"reserve\", args: [ETH] }).catch(() => bal(ETH));","title":"Keeper falls back to the splitter's whole ETH balance as the burn amount when the reserve() read fails for any reason"},{"citation":"resolved","description":"ethPerImd() fetches an IMD/ETH price from DexScreener but is never called; the burn's minimum comes from the on-chain Quoter (line 110). Dead code that reads an external price API is a maintenance trap: a future edit could wire an off-chain, manipulable price into minOut. Remove it, or use it only as a sanity bound against the Quoter.","line":43,"path":"apps/engine/src/keeper.ts","reproduction":"grep -n ethPerImd apps/engine/src/keeper.ts shows the definition at line 43 and no call site.","severity":"info","snippet":"async function ethPerImd(): Promise<number> {","title":"Unused DexScreener price helper in the keeper"}],"hash":"f5528c300d0e7e9642bec93d46d6817f844c9bc3f0963259ec376f79f35e31b3","nodeId":"0bb28d47-94a5-4dee-91ea-2687ee68e1ef","outcome":"completed","summary":"The review is complete. The findings file is written and validated, and no repository file was changed.\n\n**Scope covered.** This was the economics pass: Economic Security, Invariant and Flow Gap checks over the money paths of NIGHTSHIFT. I read the keeper, vault, withdrawal and close-out code, the x402 seller and buy handler, the IMD payment plane, the spending policy and shift loop, the frog pot and soft staking, pinball settlement, the launch spec and its on-chain verification, and the browser's swap, stake, fund and claim transactions. The repository has no Solidity, so no Foundry proofs apply. The splitter, hook and rewards contracts live outside the tree, so claims about their internals are stated as conditions.\n\n**Findings written to `.imd-findings.json`** (10 total, all paths, lines and snippets verified against the tree):\n\nMedium:\n- **Platform fee charged twice on withdrawal retry.** Fee legs are sent before the owner's share. If the owner transfer fails, the fee is booked but not credited, so the next attempt computes the fee again on the remainder. A blocklisted owner address or a receipt timeout triggers it.\n- **Frog pot never allocated for windows with no earned hours.** The epoch builder advances its block cursor even when it assigns nothing, so the frog half of those fees sits in the contract pot with no root. It also never recycles amounts the spec returns to the pot after 30 days. This is the default state in the first weeks after launch.\n- **x402 sale recorded only after the receipt.** A restart or RPC timeout between submission and receipt leaves the buyer charged with no deliverable and the revenue invisible to the owner. No pending-settlement record or reconciliation exists.\n\nLow:\n- Ledger is in memory and flushed every 3 seconds, so a crash replays withdrawn balances.\n- Failed or expired IMD orders keep their cost in the budget and books.\n- Pinball replay verification cannot stop a scripted client, and duel opponents' scores leak before settle. Matters before stakes go live.\n- Swap sell path grants the router a maximum, never-expiring Permit2 allowance.\n- Post-deploy token check is a five-selector denylist that other privileged function names pass.\n- Keeper falls back to the splitter's whole ETH balance as the burn amount on any RPC error.\n\nInfo:\n- Unused DexScreener price helper in the keeper.\n\n**Not covered in depth.** Authentication, admin token handling, Telegram moderation, MCP, CSP, service workers, and path handling were read only where they touch money flows. Those belong to the other passes.","treeHash":null,"usage":{"cachedInputTokens":2887592,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":41773,"runtime":"claude","turns":49,"wallClockMs":604875}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2565f234b0a569e9","findings":[{"citation":"resolved","description":"publicUrl() (net.ts:19-32) resolves the hostname with dns.lookup and rejects private answers, but then safeFetch passes the original URL string to fetch(), which performs its own, second DNS resolution. A hostname whose record alternates between a public address and a private one (TTL 0, a standard rebinding service) passes the check on the first lookup and connects to the private address on the second. Only ports 80 and 443 are allowed, which still covers http://169.254.169.254/ (cloud metadata, the deployment notes name an EC2 host), http://10.x/ and any neighbour on the Docker network. The URLs reaching safeFetch are third-party data: fetchDelivery() in products.ts:101-113 takes `t.result.files[...]` and `repoUrl` from the IMD job result (the swarm's agents), and checkLinks() in products.ts:83-95 takes every http(s) URL found in LLM and swarm text. fetchDelivery returns the fetched body, which #package writes into the private product file (products.ts:52) that the owner downloads and buyers receive, so the response of the internal request is exfiltrated. The same gap applies to IPv6 and to the ranges the privateIp list omits (192.0.0.0/24, 198.18.0.0/15).","line":40,"path":"apps/engine/src/net.ts","reproduction":"State: a live company whose task result (from the swarm) is `{ files: { \"guide.md\": \"http://rebind.example.net/latest/meta-data/iam/security-credentials/role\" } }`, where rebind.example.net answers 93.184.216.34 on the first query and 169.254.169.254 on the next (TTL 0). Steps: the 4-second tick calls files.ensure -> fetchDelivery -> safeFetch(u). publicUrl() lookups return 93.184.216.34 -> allowed. fetch(url) resolves again, gets 169.254.169.254, connects on port 80 and returns the metadata body. Expected: the request is refused or made only to the vetted address 93.184.216.34. Actual: the metadata response is returned, prefixed with the product head and written to data/products/<id>.md, downloadable through /api/companies/:id/products/:pid/download and every signed sale link. Fix: connect to the address that was vetted. With undici: `new Agent({ connect: { lookup: (h, o, cb) => cb(null, [{ address: vetted, family }]) } })` passed as `dispatcher`, keeping the Host/SNI from the URL; or resolve once, fetch `http://<ip>/path` with an explicit Host header and servername; and extend privateIp with 192.0.0.0/24, 198.18.0.0/15 and IPv4-mapped IPv6 inside brackets.","severity":"medium","snippet":"    const r = await fetch(url, { ...init, redirect: \"manual\" });","title":"safeFetch validates the hostname once, then lets fetch() resolve it again: DNS rebinding reaches internal services and the result is packaged into a product file"},{"citation":"resolved","description":"Sign-in is free (any wallet signs a nonce). Every other write route is wrapped in limited(...), but /api/keys POST is not, and Auth.createKey (auth.ts:95-101) pushes a record into store.apiKeys with no ceiling per owner or in total. store.apiKeys is persisted in the single state.json snapshot, which Store.flush() re-serialises in full every 3 seconds whenever anything is dirty (store.ts:57-68), and the engine marks the store dirty on every emit. Auth.owner() (auth.ts:85-90) resolves an nsk_ bearer with `this.keys.find(...)`, so every /mcp request with any nsk_-prefixed token, authenticated or not, walks the whole array. One wallet can therefore turn a free sign-in into a disk-growth and CPU problem for the server that also holds the company wallets, and a crash during flush leaves a multi-hundred-megabyte state.json to parse at the next start.","line":471,"path":"apps/engine/src/server.ts","reproduction":"Input: sign in once with any wallet (GET /api/auth/nonce, POST /api/auth/verify), then run `for i in $(seq 1 200000); do curl -s -b 'ns_session=<token>' -H 'Content-Type: application/json' -d '{\"label\":\"x\"}' -X POST https://host/api/keys; done`. Expected: a 429 after a few dozen calls, or a 400 once the wallet holds a reasonable number of keys (for example 20). Actual: every call returns 201; GET /api/keys returns 200,000 entries; data/state.json grows by roughly 150 bytes per key (about 30 MB here, unbounded in general) and is rewritten every 3 seconds; a request `curl -H 'Authorization: Bearer nsk_zzz' https://host/mcp` now scans 200,000 hashes before answering 401. Fix: wrap the route with `limited(req, \"keys\", 10, 3600_000)`, refuse createKey when `this.keys.filter(k => k.owner === owner).length >= 20`, and index keys by hash in a Map instead of Array.find.","severity":"medium","snippet":"        if (req.method === \"POST\") { const b = await body(req); return json(res, 201, engine.auth.createKey(owner, String(b.label ?? \"MCP\"))); }","title":"POST /api/keys has no rate limit and no per-owner cap: any signed-in wallet grows state.json without bound and makes every nsk_ lookup a linear scan"},{"citation":"resolved","description":"The comment above this line states the control: settings that move money or trust are announced so that a leaked admin token cannot act quietly. The list omits PLATFORM_DEV, which receives 20% of every platform fee in Engine.#withdraw (engine.ts:919-920, 933), and omits TELEGRAM_CHAT_ID, TELEGRAM_BOT_TOKEN, TELEGRAM_ADMIN_IDS and LAUNCH_NOTIFY, which decide where every alert and admin card goes and who may run bot commands. All of them are in EDITABLE (settings.ts:9-19) and are written by saveSetup -> writeSettings without validation beyond isAddress. POST /api/admin/restart (server.ts:349) applies them. An attacker holding the admin token can first point TELEGRAM_CHAT_ID at a chat they control and restart, after which the alerts for the announced keys reach only them; or simply change PLATFORM_DEV and collect the dev share of every withdrawal with no message to the operator at all.","line":259,"path":"apps/engine/src/server.ts","reproduction":"Input: `curl -X POST https://host/api/admin/setup -H 'x-admin-token: <leaked>' -H 'Content-Type: application/json' -d '{\"PLATFORM_DEV\":\"0xAttackerAttackerAttackerAttackerAttack01\"}'` then `curl -X POST https://host/api/admin/restart -H 'x-admin-token: <leaked>'`. Expected: an operator alert naming PLATFORM_DEV, as for PLATFORM_TREASURY. Actual: 200 with the new setup state and no engine.alert call (`touched` is empty); after the restart, the next owner withdrawal of $100 with PLATFORM_FEE_BPS=500 sends the $1 dev part to the attacker's address (engine.ts:933) and the operator's Telegram is silent. Same for `{\"TELEGRAM_CHAT_ID\":\"<attacker chat>\"}`: no alert, and after restart every later alert, including the ones for the announced keys, goes to the attacker. Fix: add PLATFORM_DEV, TELEGRAM_CHAT_ID, TELEGRAM_BOT_TOKEN, TELEGRAM_ADMIN_IDS and LAUNCH_NOTIFY to `touched`, send the alert before writeSettings and to the chat that was configured before the change, and alert on /api/admin/restart as well.","severity":"medium","snippet":"          const touched = [\"NIGHTSHIFT_MODE\", \"PLATFORM_TREASURY\", \"PLATFORM_FEE_BPS\", \"ETH_RPC\", \"SALES_RPC\", \"SALES_CHAIN_ID\", \"TOKEN_ADDRESS\", \"NFT_ADDRESS\", \"REQUIRE_AUTH\"].filter((k) => k in patch);","title":"The /setup change alert misses the keys that redirect the dev fee and the alert channel itself, so a leaked admin token can act quietly despite the stated control"},{"citation":"resolved","description":"soloStart (pinball.ts:188-194) returns the seed immediately and soloFinish verifies nothing except that the last input tick is not ahead of wall-clock time (minus 3 s) before replaying. The engine is deterministic and shipped to every browser as the same file (apps/web/src/pinball/engine.ts, kept byte-identical to the server copy), so a client can run replay(seed, candidate) locally as many times as it likes, keep the best input list, wait lastTick/240 seconds, and submit it. The server's replay then confirms the score, records it in boards.solo (persisted, shown at /api/pinball/board) and tell() posts \"NEW ALL-TIME SOLO RECORD ... Verified by replay\" to the community chat (engine.ts:166). Duels work the same way: the seed is visible SEED_LEAD before start and finish() applies only the same time bound (pinball.ts:142). Severity is low today because STAKES_LIVE is false (pinball.ts:31); it becomes a funds issue the day IMD or SHIFT stakes are escrowed on this result, and the community-wide announcements already carry a false \"verified\" claim.","line":200,"path":"apps/engine/src/pinball.ts","reproduction":"Input: `POST /api/pinball/solo {\"name\":\"bot\"}` -> `{id, seed}`. Locally (node, with the shipped engine.ts): `let best=null; for (let n=0;n<20000;n++){ const inputs=[[0,0,0,1],[240,0,0,0], ...random flipper taps at random ticks...]; const s=replay(seed,inputs).score; if(!best||s>best.score) best={score:s,inputs}; }`; sleep `max tick / 240` seconds; `POST /api/pinball/solo/<id>/finish {\"inputs\": best.inputs}`. Expected: a score a human could reach in one real-time attempt, or a rejection. Actual: 200 with the search-optimised score; it appears at the top of /api/pinball/board and, if it beats the record, a \"verified by replay\" record card is posted to Telegram. Fix: do not treat replay as proof of human play. Either (a) make the game server-authoritative in time (reveal the seed only through the live match clock, require inputs to be streamed to /progress as they happen and replay only those, rejecting a finish whose inputs were not seen live within a small tolerance), or (b) keep boards informational and never let STAKES_LIVE depend on this check; at minimum drop the words \"Verified by replay\" from the record posts.","severity":"low","snippet":"  if (Date.now() - r.startedAt < (lastTick / 240) * 1000 - 3_000) throw new Error(\"inputs from later than the run has lasted\");","title":"Solo and duel results accept inputs optimised offline: the seed is handed out up front and the only check is wall-clock, so a bot takes the public boards and the record announcements"},{"citation":"resolved","description":"queue() stores an avatar of up to 200,000 characters per player. view() (pinball.ts:65-68) includes `avatar` for both players in every event, and progress() (pinball.ts:111-123) calls emit(m) on each POST, which writes the full view to every SSE listener of the match (server.ts:400). The progress route allows 600 POSTs a minute per IP (server.ts:406), so a single player with a maximal avatar makes the server send up to 600 x 200 KB = 120 MB a minute to the opponent and to every spectator stream of that match, and it does so from the server's own uplink. The avatar is also never checked to be a real SVG, so it is simply a 200 KB payload the server will repeat on request.","line":82,"path":"apps/engine/src/pinball.ts","reproduction":"Input: `POST /api/pinball/queue {\"name\":\"a\",\"currency\":\"FREE\",\"stake\":0,\"avatar\":\"data:image/svg+xml;base64,\" + \"A\".repeat(199000)}` from one client, a second client queues normally and opens `GET /api/pinball/match/<id>/events`. Then from the first client: `for i in $(seq 1 600); do curl -s -X POST https://host/api/pinball/match/<id>/progress -H 'Content-Type: application/json' -d '{\"player\":\"<pid>\",\"score\":1,\"tick\":'$i'}'; done` within a minute. Expected: progress events are small (score, tick, position, attacks). Actual: each SSE event is about 200 KB; the opponent's stream receives about 120 MB in that minute, the server sends it once per listener. Fix: send the avatar once in the first event or as a separate field fetched by id, cap data: avatars at a few KB (an onchain frog SVG is small), and validate that the base64 decodes to an SVG document.","severity":"low","snippet":"  const face = avatar && (/^\\/games\\/pinball\\/frog-\\d\\.webp$/.test(avatar) || /^data:image\\/svg\\+xml;base64,[A-Za-z0-9+/=]{100,200000}$/.test(avatar)) ? avatar : null;","title":"A 200 KB data: avatar is relayed to every match listener on each progress POST: one player can push hundreds of megabytes a minute through the server to their opponent"},{"citation":"resolved","description":"Vault (vault.ts:25-30) writes secret.key next to vault.json when NIGHTSHIFT_SECRET is unset; the AES-GCM layer then protects nothing against anyone who obtains the data volume (backup, snapshot, misconfigured volume mount), since the key sits beside the ciphertext. The same secret also derives the session MAC key (auth.ts:24) and the download-link key (products.ts:22), so possession of the volume also forges owner sessions for every wallet. The code comment calls this 'wrong for production', yet setupState marks it 'warn', and readyForLive (admin.ts:75) only considers 'block' states, so the dashboard shows the operator a green 'ready for live' with real funds while the vault key is unprotected. The 'auth' and 'url' checks, by contrast, do escalate to 'block' in live mode.","line":57,"path":"apps/engine/src/admin.ts","reproduction":"State: container started with NIGHTSHIFT_MODE=live and no NIGHTSHIFT_SECRET (the docker-compose and Dockerfile set neither). Input: `curl -H 'x-admin-token: <token>' https://host/api/admin/setup`. Expected: checks[secret].state == \"block\" and readyForLive == false in live mode until NIGHTSHIFT_SECRET is set. Actual: checks[secret].state == \"warn\" and readyForLive == true. Anyone who copies /data can run `scryptSync(readFileSync('secret.key','utf8').trim(), 'nightshift-vault-v1', 32)` and decrypt every entry of vault.json (keeper and all company wallets), and mint `s.<owner>.<exp>.<hmac>` sessions for any owner. Fix: `state: process.env.NIGHTSHIFT_SECRET ? \"ok\" : live ? \"block\" : \"warn\"`, and refuse to start in live mode when `vault.generatedSecret` is true.","severity":"low","snippet":"    { id: \"secret\", label: \"Wallet vault key\", state: process.env.NIGHTSHIFT_SECRET ? \"ok\" : \"warn\",","title":"Live-readiness treats a generated vault secret as a warning, so /setup reports 'ready for live' while every company key is recoverable from the same volume"}],"hash":"fcc559c2cde66cb56050bbd272f0232dae3d5d4a8035a6fb12b50e25d45d48fa","nodeId":"9668a845-aec3-44c1-a23f-16cf843ebc3e","outcome":"completed","summary":"Audit complete. The findings file is written and validated: all six entries parse, and each quoted snippet matches its file and line in the tree. No repository files were changed.\n\n**Scope covered.** I read the whole engine (server, auth, admin, settings, vault, mcp, keeper, pinball and its physics engine, products, net, engine, social, token, frogs, frogpot, store, chain), the x402 seller, the core spending policy and shift loop, the IMD HTTP client's payment guard, and the web transaction builders (swap, stake, frog claims, frog mint, funding, admin keeper and root posting), the pinned addresses, both service workers, the CSP and the deployment files. The permission model holds up well: live mode forces wallet sign-in, every company control compares the session owner to the stored owner, withdrawals and close-out go only to the signed-in owner, API keys cannot mint keys, admin routes use a header token with no cookie exposure, cross-site POSTs are refused, and every browser transaction targets addresses pinned in the bundle rather than values from the server.\n\n**Findings, by severity.**\n\n- **Medium, SSRF by DNS rebinding** in `apps/engine/src/net.ts:40`. The host is vetted with one lookup, then fetch resolves again. Swarm-supplied delivery URLs can reach cloud metadata or Docker neighbours, and the response is written into the sold product file.\n- **Medium, unbounded API-key minting** in `apps/engine/src/server.ts:471`. No rate limit or per-owner cap. Any wallet can grow the single state file without limit and turn each key lookup into a full linear scan.\n- **Medium, silent admin changes** in `apps/engine/src/server.ts:259`. The alert for money-moving settings omits the dev fee wallet and the Telegram chat and token keys, so a leaked admin token can redirect the dev share or reroute alerts without notice.\n- **Low, offline-optimised pinball scores** in `apps/engine/src/pinball.ts:200`. The seed is public up front and only wall-clock is checked, so a bot can search for the best input list and still pass replay. Harmless while stakes are off, fund-moving the day they turn on.\n- **Low, avatar relay amplification** in `apps/engine/src/pinball.ts:82`. A 200 KB data URI is re-sent on every progress event, up to 120 MB a minute per listener.\n- **Low, vault secret readiness check** in `apps/engine/src/admin.ts:57`. A generated secret stored beside the ciphertext is reported as a warning, so the dashboard says ready for live while every wallet key and session MAC is recoverable from the data volume.\n\n**Trust assumptions, not defects.** The admin token holder can change RPC endpoints, fee recipients and mode, then restart. The dev wallet names the keeper and posts frog reward roots. Sim mode intentionally leaves companies open to anyone. The keeper wallet only pays gas, as the splitter design states.\n\n**Not verified.** I did not run the services or reproduce against the live deployment, and no Solidity is in this tree, so no Foundry proofs apply. The pool key, hook and reward addresses pinned in `apps/web/src/shift.ts` were not checked against chain state.","treeHash":null,"usage":{"cachedInputTokens":3015867,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":35584,"runtime":"claude","turns":39,"wallClockMs":678546}}],"verification":[]}