# AGENTS.md

## What this repository is

A VERIFI engine v2 component — a **harness** (TypeScript, drives an assessment) or an
**MCP** (Python, exposes tools to one). The normative reference is the developer
documentation at https://docs.verifi.pragyacyber.com/ . Read the relevant section before writing code.

You do NOT have, and do not need, a checkout of the VERIFI platform repository. Everything
you need is in this repo's `node_modules`, on that site, or below.

## Hard rules

1. **Never invent an API.** For TypeScript, if you cannot point at the exported symbol in
   `node_modules/@pragyacyber/harness-sdk/dist/index.d.ts` or
   `node_modules/@pragyacyber/engine-contract/dist/index.d.ts`, it does not exist.
   Read the `.d.ts`. Do not infer from the name. If the packages are not installed,
   say so and stop — do not write code against a remembered shape.
2. **Never write a credential into a file, a log, a test fixture, or a sample** —
   not even a fake-looking one. Credentials are indirections: `secret:<id>`,
   `env:<VAR>`, or `bedrock-sts`. That pattern is enforced by the contract schema.
   The registry token belongs in `$NODE_AUTH_TOKEN`, referenced from `.npmrc`, never
   pasted into it.
3. **Never add anything that can refuse a spend.** `ctx.budget` meters and alerts.
   There is no `canProceed`, no `enforce`, no cap. Time is the only hard bound.
4. **Never make a test pass by making the fake agree with the code.** A fake MCP
   that answers whatever we send proves nothing. Test against the real protocol
   or against a real container.
5. **Never report success without showing output.** See the last section.
6. **Do not commit, push, or deploy** unless explicitly told to in the moment.

## Getting the packages installed (TypeScript only)

The SDKs are PRIVATE packages in GitHub Packages under the `@pragyacyber` scope. They
are not on npmjs.com.

```
# .npmrc — checked in. The token is REFERENCED, never written here.
@pragyacyber:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
```

```bash
gh auth refresh -s read:packages          # if gh is available
export NODE_AUTH_TOKEN=$(gh auth token)
npm view @pragyacyber/harness-sdk version # prove auth BEFORE writing code
```

If that returns `404`, the human's account has not been granted read access to the
package. That is not fixable from here. STOP and tell them to request access — for
their GitHub username, and separately for each `owner/repo` whose CI will install
(a manual grant on the package's "Manage Actions access" page, once per repository).

In a Dockerfile, pass the token as a BuildKit secret, NEVER a build ARG — an ARG is
readable in the image history and bakes the credential into every pulled layer:

```dockerfile
RUN --mount=type=secret,id=npm_token \
    { echo "@pragyacyber:registry=https://npm.pkg.github.com"; \
      echo "//npm.pkg.github.com/:_authToken=$(cat /run/secrets/npm_token)"; } > .npmrc \
    && npm ci --omit=dev \
    && rm -f .npmrc
```

## Versions

`@pragyacyber/engine-contract@1.5.1` · `@pragyacyber/harness-sdk@1.2.0`.
Both are `1.x`: install with a CARET, `^1.0.0`. Do not pin exactly — that was a
workaround for `0.x` caret semantics and is no longer needed. Never end up with
two copies of `engine-contract` in one tree; check with
`npm ls @pragyacyber/engine-contract`.

## Verified facts about the SDKs

- `@pragyacyber/harness-sdk` runtime exports, in full:
  `BudgetLedger`, `ConnectorVersionError`, `FailoverSlot`, `GrpcEngineChannel`,
  `MODEL_PRICES_PER_MTOK`, `McpClient`, `ModelClient`, `ModelRoleNotBoundError`,
  `SDK_VERSION`, `SlotNotFilledError`, `collectRunSecrets`, `createMcpResolver`,
  `createModelResolver`, `credentialsFromEnv`, `defineHarness`, `i`, `isHarness`,
  `loadEngineProto`, `priceFor`, `protoPath`, `redact`, `resolveAwsCredentials`,
  `runHarness`, `zPlanDeclaration`. Everything else in the package is a type-only export.
- `isCompatible()` compares MAJOR only and returns false for anything unparseable.
- `defineHarness` throws if `models` is empty. Declare at least one role even if
  the harness never calls `ctx.model()`; mark it `required: false`.
- `defineHarness` throws on a phase `order` that is not a POSITIVE INTEGER.
  Number phases from 1. `0` and `1.5` both throw.
- `ctx.mcp(slot).call()` returns `{ ok, text, isError, latencyMs, error?, raw? }`.
  It NEVER throws. A failed tool is `ok: false`.
- `ctx.mcp(slot).listTools()` DOES throw on a JSON-RPC error.
- `ctx.emitFinding(f)` is typed `RawFinding` from `@pragyacyber/engine-contract`.
  The compiler checks the field names. It is validated again by the engine.
- `ctx.target` (a `TargetScope`) and `ctx.hardDeadline` (ISO-8601) ARE on the
  context. `ctx.target.scope` is the authorized in-scope URL list — check against
  it rather than trusting an input parameter. `ctx.credentialRefs` and
  `ctx.composition` are NOT on the context.
- There is no `ctx.http`, no `ctx.fetch`, no way to get a raw client. That is
  deliberate: every spend must pass the ledger.
- `ctx.model()` builds every request through the contract (`buildModelRequest`),
  so every modelled provider works: `bedrock` signs with the run's task role;
  `anthropic`, `openai_compat`, `nim` and `vertex` use a per-role key the engine
  injects as `VERIFI_MODEL_<ROLE>_PROVIDER` / `_BASEURL` / `_APIKEY` (role upper-cased,
  non-alphanumerics → `_`); with none injected the role falls back to `bedrock`. A failed
  call returns `ok: false`, never throws. It DOES throw only when NO AWS credentials
  resolve at all (normal locally). Contract routing landed in harness-sdk 1.1.0; the
  per-role keyed-provider injection in 1.2.0.
- The harness never opens a listening port. It dials out, works, and exits. A
  Dockerfile for one has no `EXPOSE` and no `HEALTHCHECK`.

## Harness rules

- Emit results as you produce them with `ctx.emitFinding`. Never batch to the end.
  The engine persists each finding on arrival, so a run that dies late keeps
  everything it already found.
- Give every emission a deterministic `findingKey`. No timestamps, no run ids, no
  counters, no severity in the key. Shape: `<check-id>:<asset>`.
- Valid severities: `critical`, `high`, `medium`, `low`, `info`. `informational`
  is REFUSED and the error names `info`. Field names that get silently ignored if
  you guess: `cves` is PLURAL, `affected` is `{url, parameter}[]` not a string,
  the stable id is `findingKey` not `dedupeKey`.
- Valid confidences: `confirmed`, `firm`, `tentative`. Default `firm`.
- Never emit `reviewStatus`, `publishedAt`, `reviewedBy` or `clientVisible`.
  Both the schema and the engine refuse them. Review status is set by an analyst,
  never by a harness.
- Always write `description`, `impact` and `remediation`. A bare tool string is
  not a description.
- `declarePlan()` is a STATIC declaration, not the execution plan. Never describe
  it as one.
- Clamp any tool timeout below 285s. `ctx.mcp().call()` aborts at 300s, and while
  `createMcpResolver` accepts a `timeoutMs`, `runHarness` does not expose one —
  so through the real entrypoint the ceiling stands.
- Pass `workspace: ctx.runId` to any shared execution MCP.
- A slot's first failed call latches it to its fallback for the rest of the run.
  Do not write your own retry loop on top of that.
- Register the image by DIGEST. `imageDigest` must match
  `/^sha256:[0-9a-f]{64}$/` — a tag is refused, and a harness with no digest can
  never be probed.

## MCP rules (Python + FastMCP)

Write Python. `@pragyacyber/mcp-sdk`'s `buildMcpServer` serves a plain-JSON
`{method:'list_tools'}` envelope, NOT MCP JSON-RPC, so a server built on it
answers 400 to `initialize` and can never pass gate 3. Its `defineMcp` rules are
right and are the ones below.

- `GET /healthz` is liveness ONLY. It must touch no backend.
- `GET /test` must exercise the REAL backend and return 2xx with a JSON body
  containing `ok: true`. Return 503 when unhealthy, so a caller checking only the
  status code still gets the right answer.
- Execute your binaries to check them. `shutil.which` is not enough — a truncated
  download resolves on PATH and fails on every call.
- Every tool doing blocking work must be `async def` + `anyio.to_thread.run_sync`.
  A blocking sync tool freezes the event loop and the health probes with it, so the
  registry marks the server unavailable during every scan it is working perfectly.
- At least one tool, or gate 3 can never pass.
- Every tool states whether it is destructive, in its docstring.
- Derive every path from a caller-supplied `workspace`. Guard against `..` and
  absolute paths. Never fall back to `$HOME`.
- Bound timeouts, output size, and file transfer size. Clamp the caller's request
  to the server's ceiling.
- Report every failure in the return value. Never return an empty success.
- Register as `sidecar`. `shared` is not even representable on a registration —
  it requires a PASSED statelessness test, and declaring it is asserting an
  outcome.
- A registration cannot carry `status`, `probe`, `probeInvoked`, `tools` or
  `authRef`. Those are what a probe FOUND. The schemas are `.strict()`, so
  sending one is a 400.

## Before you claim it works

- Harness: `npx tsc --noEmit`, then actually run it against a fake `EngineChannel`
  and paste the output. State whether a finding was emitted and which events fired.
- MCP: run all three gates with curl against a live server and paste the output.
  Count the tools in `tools/list` yourself.
- Never claim a probe passed without showing the response.
- A green dry run proves WIRING, not findings. Never report it as evidence the
  scanner works.
