diff --git a/app/auth.py b/app/auth.py index b592824..44de84b 100644 --- a/app/auth.py +++ b/app/auth.py @@ -31,6 +31,37 @@ def emr_recall_api_key() -> str | None: return key or None +def emr_writer_api_key() -> str | None: + """EMR_WRITER_API_KEY: a scoped credential that may call the seven read tools AND emr_remember / emr_upsert, and nothing else (docs/writer_credential.md).""" + key = (os.getenv("EMR_WRITER_API_KEY") or "").strip() + return key or None + + +MIN_WRITER_KEY_CHARS = 32 + + +def writer_key_problems() -> list[str]: + """What EMR_WRITER_API_KEY refuses to start with: too short, or the same string as another credential (a writer key that IS the ledger key would be the operator key).""" + writer = emr_writer_api_key() + if not writer: + return [] + problems = [] + if len(writer) < MIN_WRITER_KEY_CHARS: + problems.append(f"EMR_WRITER_API_KEY is shorter than {MIN_WRITER_KEY_CHARS} characters") + if is_public_deployment(): + problems.append("EMR_WRITER_API_KEY is not supported on a public deployment (the recall key is the one credential there)") + for name, other in (("JARVIS_API_KEY", configured_api_key()), ("EMR_RECALL_API_KEY", emr_recall_api_key())): + if other and secrets.compare_digest(writer, other): + problems.append(f"EMR_WRITER_API_KEY must not equal {name}") + return problems + + +def assert_writer_key_config() -> None: + problems = writer_key_problems() + if problems: + raise RuntimeError("EMR_WRITER_API_KEY refuses to start: " + "; ".join(problems)) + + def memory_write_enabled() -> bool: """Gate for REST ledger CRUD / EMR reinforce / board mutations.""" default = "false" if is_public_deployment() else "true" @@ -222,25 +253,32 @@ def require_emr_recall_api_key( verify_operator_api_key(authorization, x_emr_recall_key) -def credential_kind(authorization: str | None, x_emr_recall_key: str | None, x_api_key: str | None) -> str | None: - """Which credential does a tool or MCP request carry? ``operator`` (the ledger key), ``recall`` (EMR_RECALL_API_KEY: read tools only), or None. +def credential_kind(authorization: str | None, x_emr_recall_key: str | None, x_api_key: str | None, x_emr_writer_key: str | None = None) -> str | None: + """Which credential does a tool or MCP request carry? ``operator`` (the ledger key), ``writer`` (EMR_WRITER_API_KEY: the read tools plus emr_remember / emr_upsert), + ``recall`` (EMR_RECALL_API_KEY: read tools only), or None. - The recall key is a *scoped* credential: it never opens call log, drift, writes, administration or any REST ledger route (see docs/recall_credential.md). - The ledger key keeps working on the tool routes when a recall key is configured, so installing one breaks no existing client. On a PUBLIC deployment the - old rule stays: the recall key is the one operator credential there (the ledger key is not accepted on the tool routes).""" + The recall and writer keys are *scoped* credentials: neither opens call log, drift, administration or any REST ledger route (docs/recall_credential.md, + docs/writer_credential.md). The ledger key keeps working on the tool routes when they are configured, so installing one breaks no existing client, and it wins when + several are presented. The writer key is accepted as a Bearer or in X-EMR-Writer-Key, never in X-API-Key. On a PUBLIC deployment the old rule stays: the recall key is + the one operator credential there (the ledger key and the writer key are not accepted on the tool routes).""" recall = emr_recall_api_key() + writer = emr_writer_api_key() ledger = configured_api_key() bearer = _extract_bearer(authorization) recall_token = bearer or (x_emr_recall_key or "").strip() recall_match = bool(recall and recall_token and secrets.compare_digest(recall_token, recall)) + writer_token = bearer or (x_emr_writer_key or "").strip() + writer_match = bool(writer and writer_token and secrets.compare_digest(writer_token, writer)) if is_public_deployment(): return "operator" if recall_match else None # the old rule: on a public deployment the recall key is the operator credential if ledger is None: - if recall: - return "recall" if recall_match else None # still read-only with no ledger key configured: operator-only operations fail closed (nobody is the operator) + if recall or writer: + return "writer" if writer_match else ("recall" if recall_match else None) # still scoped with no ledger key configured: operator-only operations fail closed (nobody is the operator) return "operator" if allow_unauthenticated() else None if any(t and secrets.compare_digest(t, ledger) for t in (bearer, (x_api_key or "").strip())): # (either one: see presented_ledger_candidates) return "operator" # (the ledger key wins when both are presented) + if writer_match: + return "writer" return "recall" if recall_match else None @@ -249,42 +287,63 @@ def require_tools_credential( authorization: str | None = Header(default=None), x_emr_recall_key: str | None = Header(default=None, alias="X-EMR-Recall-Key"), x_api_key: str | None = Header(default=None, alias="X-API-Key"), + x_emr_writer_key: str | None = Header(default=None, alias="X-EMR-Writer-Key"), ) -> None: - """The read tool routes: the ledger key (operator) or, when configured, the recall key (read-only scope). Records which one on the request.""" + """The read tool routes: the ledger key (operator) or, when configured, the writer key (reads + the two draft writes) or the recall key (read-only scope). Records which one on the request.""" if tenant_identity_in_force(): return # the identity middleware authenticated the OAuth token / tenant key before this dependency if is_public_deployment() and not emr_recall_api_key(): raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY") - kind = credential_kind(authorization, x_emr_recall_key, x_api_key) + kind = credential_kind(authorization, x_emr_recall_key, x_api_key, x_emr_writer_key) if kind is None: raise HTTPException(status_code=401, detail="Invalid or missing operator API key" if emr_recall_api_key() else "Invalid or missing API key") request.scope["jarvis_credential"] = kind +def require_tools_write_credential( + request: Request, + authorization: str | None = Header(default=None), + x_emr_recall_key: str | None = Header(default=None, alias="X-EMR-Recall-Key"), + x_api_key: str | None = Header(default=None, alias="X-API-Key"), + x_emr_writer_key: str | None = Header(default=None, alias="X-EMR-Writer-Key"), +) -> None: + """emr_remember / emr_upsert over REST: the LEDGER key or the WRITER key; the recall key is refused (401).""" + if tenant_identity_in_force(): + return # (a tenant reaches the operator gates below this and is refused there, 403) + kind = credential_kind(authorization, x_emr_recall_key, x_api_key, x_emr_writer_key) + if kind in ("operator", "writer"): + request.scope["jarvis_credential"] = kind + return + if is_public_deployment() and not emr_recall_api_key(): + raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY") + raise HTTPException(status_code=401, detail="Invalid or missing operator API key" if emr_recall_api_key() else "Invalid or missing API key") + + def require_ledger_operator_key( authorization: str | None = Header(default=None), x_emr_recall_key: str | None = Header(default=None, alias="X-EMR-Recall-Key"), x_api_key: str | None = Header(default=None, alias="X-API-Key"), + x_emr_writer_key: str | None = Header(default=None, alias="X-EMR-Writer-Key"), ) -> None: """Call log, drift, tool writes and the nx routes: the LEDGER key, never the recall key (a recall-only caller is refused 401). With no recall key configured this is exactly what it was (the ledger key). On a public deployment the recall key stays the operator credential.""" if tenant_identity_in_force(): return # (a tenant reaches the operator gates below this and is refused there, 403) - if credential_kind(authorization, x_emr_recall_key, x_api_key) == "operator": + if credential_kind(authorization, x_emr_recall_key, x_api_key, x_emr_writer_key) == "operator": return if is_public_deployment() and not emr_recall_api_key(): raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY") raise HTTPException(status_code=401, detail="Invalid or missing operator API key" if emr_recall_api_key() else "Invalid or missing API key") -def mcp_credential(authorization: str | None, x_emr_recall_key: str | None, x_api_key: str | None) -> str: - """The MCP endpoint's check: raises like the tool routes do, and returns which credential the request carries (``operator`` or ``recall``).""" +def mcp_credential(authorization: str | None, x_emr_recall_key: str | None, x_api_key: str | None, x_emr_writer_key: str | None = None) -> str: + """The MCP endpoint's check: raises like the tool routes do, and returns which credential the request carries (``operator``, ``writer`` or ``recall``).""" if tenant_identity_in_force(): return "operator" # (as before: the identity middleware validated the token and bound the tenant; "operator" here only means "not recall-scoped") if is_public_deployment() and not emr_recall_api_key(): raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY") - kind = credential_kind(authorization, x_emr_recall_key, x_api_key) + kind = credential_kind(authorization, x_emr_recall_key, x_api_key, x_emr_writer_key) if kind is None: raise HTTPException(status_code=401, detail="Invalid or missing operator API key" if emr_recall_api_key() else "Invalid or missing API key") return kind @@ -382,7 +441,8 @@ async def ledger_read_protection_middleware(request: Request, call_next): # deployment the recall key stays the operator credential, as before. if is_public_deployment() and not emr_recall_api_key(): raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY") - if credential_kind(request.headers.get("authorization"), request.headers.get("x-emr-recall-key"), request.headers.get("x-api-key")) != "operator": + if credential_kind(request.headers.get("authorization"), request.headers.get("x-emr-recall-key"), request.headers.get("x-api-key"), + request.headers.get("x-emr-writer-key")) != "operator": raise HTTPException(status_code=401, detail="Invalid or missing operator API key") except HTTPException as exc: return json_response(exc.status_code, exc.detail, headers=getattr(exc, "headers", None), path=request.url.path) @@ -559,6 +619,11 @@ def credential_is_operator(headers, path: str) -> bool: return False elif is_public_deployment(): return False + writer = emr_writer_api_key() + if writer and not is_public_deployment(): + wtoken = bearer or (headers.get("x-emr-writer-key") or "").strip() + if wtoken and secrets.compare_digest(wtoken, writer): + return True # likewise the writer key: it reaches the operator's ledger through the tool routes; credential_kind decides which tools expected = configured_api_key() if expected is None: return allow_unauthenticated() diff --git a/app/main.py b/app/main.py index 7fe4aaf..a416f52 100644 --- a/app/main.py +++ b/app/main.py @@ -86,8 +86,10 @@ ledger_read_protection_middleware, mcp_write_enabled, memory_write_enabled, + assert_writer_key_config, require_ledger_operator_key, require_tools_credential, + require_tools_write_credential, require_mcp_write_scope, require_memory_write, assert_multi_tenant_config, @@ -142,6 +144,7 @@ async def lifespan(app: FastAPI): """Application lifespan: track and cleanup background nx-search processes.""" # Store for background processes assert_multi_tenant_config() # JARVIS_MULTI_TENANT: refuse to start with a configuration that could fail open + assert_writer_key_config() # EMR_WRITER_API_KEY: refuse a weak writer key, one equal to another credential, or one on a public deployment assert_rate_limit_config() # per-process limiters: more than one worker multiplies every quota, so refuse unless a shared limiter is declared if key_auth.key_tenants_enabled(): key_auth.assert_config() # JARVIS_KEY_TENANTS: needs the row store and a strong pepper @@ -1118,7 +1121,7 @@ def tool_emr_recall(body: EmrRecallRequest): @app.post( "/api/jarvis/tools/emr_remember", - dependencies=[Depends(require_ledger_operator_key), Depends(require_mcp_write_scope)], + dependencies=[Depends(require_tools_write_credential), Depends(require_mcp_write_scope)], ) def tool_emr_remember(body: EmrRememberRequest): """Governed create via EMR — draft-only; gated by JARVIS_MCP_WRITE_ENABLED.""" @@ -1128,7 +1131,7 @@ def tool_emr_remember(body: EmrRememberRequest): @app.post( "/api/jarvis/tools/emr_upsert", - dependencies=[Depends(require_ledger_operator_key), Depends(require_mcp_write_scope)], + dependencies=[Depends(require_tools_write_credential), Depends(require_mcp_write_scope)], ) def tool_emr_upsert(body: EmrUpsertRequest): """Governed supersede via EMR — new draft + archive prior; gated by JARVIS_MCP_WRITE_ENABLED.""" diff --git a/deploy/mint/README.md b/deploy/mint/README.md index 686f70e..95dd5ce 100644 --- a/deploy/mint/README.md +++ b/deploy/mint/README.md @@ -232,10 +232,15 @@ does have one: run `app.pg_schema.V9_ROLLBACK` as the migration role in one tran ### Per-tenant API keys (`jarvisctl keys`) -`jarvisctl keys pepper-init|pepper-install-app|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status` issues and manages per-tenant API keys (schema v10; `docs/tenant_keys.md`). Tokens are written to a +`jarvisctl keys pepper-init|pepper-install-app|writer-key-init|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status` issues and manages per-tenant API keys (schema v10; `docs/tenant_keys.md`). Tokens are written to a mode-600 file under `secrets/issued-keys/` and never printed; the pepper (`secrets/key-pepper`) is created once and never overwritten or shown. Key authentication stays off until `JARVIS_KEY_TENANTS=1` is set for the app (see `docs/tenant_keys.md`); issuing a key does not enable it. +### The writer credential (`EMR_WRITER_API_KEY`) + +`jarvisctl keys writer-key-init` creates a scoped key that can use the read tools plus `emr_remember` / `emr_upsert` and nothing else (`docs/writer_credential.md`): the key to give a second machine instead of the ledger +key. It is installed by recreating the app; it does not change `JARVIS_MCP_WRITE_ENABLED`, which stays the deployment's own switch. + ### Signatures (schema v7) Backup sets carry `.signatures.json` (the attestations, trust statements and blocks, for the PC witness) and the anchors carry diff --git a/deploy/mint/bin/jarvisctl b/deploy/mint/bin/jarvisctl index 7efa339..b11b4ea 100755 --- a/deploy/mint/bin/jarvisctl +++ b/deploy/mint/bin/jarvisctl @@ -22,7 +22,7 @@ jarvisctl seal [--force|--status] seal new history into Continuity Blocks now (or show the unsealed tail) attest status|sign|init-key|install-roots|verify signatures: the host-side signer, the trust roots, verification tenants list|seal|sign|status|verify per-tenant versions of seal / attest sign / verify (new; the timers still run the operator-only ones) - keys pepper-init|pepper-install-app|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status per-tenant API keys (RFC phase 1): issue, revoke, list; tokens go to a mode-600 file, never the screen + keys pepper-init|pepper-install-app|writer-key-init|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status per-tenant API keys (RFC phase 1): issue, revoke, list; tokens go to a mode-600 file, never the screen replay state|receipt|receipts|check|verify Replay Contracts: replay the ledger at a point, receipts at sealed points, re-derive them backup take a backup set now prune [--dry-run] apply the retention policy diff --git a/deploy/mint/bin/keys.sh b/deploy/mint/bin/keys.sh index e6a9953..5b7db5e 100755 --- a/deploy/mint/bin/keys.sh +++ b/deploy/mint/bin/keys.sh @@ -11,6 +11,8 @@ # keys.sh tenants list tenants # keys.sh keys [--tenant t_] list keys (never a hash) # keys.sh pepper-install-app make the app's own copy of the pepper (secrets/key-pepper.app, owned by the app's uid 10001, mode 400); the operator's copy stays yours +# keys.sh writer-key-init generate the WRITER key (EMR_WRITER_API_KEY: read tools + emr_remember / emr_upsert only): secrets/writer.key (mode 600) and one line in +# secrets/app.env; never overwritten, never printed. The app picks it up at the next recreate; copy writer.key to the machine that needs it. # keys.sh status pepper present? app copy present and identical? is JARVIS_KEY_TENANTS set in the app env? how many tenants and keys # # Issuing a key does NOT turn key authentication on: the app only honours keys with JARVIS_KEY_TENANTS=1 and a pepper mounted for it (docs/tenant_keys.md). Every argument is @@ -20,7 +22,7 @@ export LOG_NAME=keys # shellcheck source=lib.sh source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -usage() { sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2; } +usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2; } bad() { echo "$*" >&2; exit 2; } [ $# -ge 1 ] || usage cmd="$1"; shift @@ -78,6 +80,20 @@ case "$cmd" in || die "could not create the app's copy of the pepper" log INFO "app copy written to $APP_PEPPER_FILE (owner 10001, mode 400; the value is not shown). Mount THIS file into the app as JARVIS_KEY_PEPPER_FILE; never the operator copy." ;; + writer-key-init) + [ $# -eq 0 ] || bad "writer-key-init takes no options" + WRITER_FILE="$SECRETS_DIR/writer.key"; APP_ENV="$SECRETS_DIR/app.env" + [ -f "$APP_ENV" ] || die "no $APP_ENV: run jarvisctl secrets first" + [ ! -e "$WRITER_FILE" ] || die "$WRITER_FILE already exists: the writer key is never overwritten (delete it AND its EMR_WRITER_API_KEY line to replace it)" + ! grep -qE '^[[:space:]]*EMR_WRITER_API_KEY[[:space:]]*=' "$APP_ENV" || die "$APP_ENV already sets EMR_WRITER_API_KEY: not adding a second one" + ( umask 077 + key="$(head -c 32 /dev/urandom | base64 -w0 | tr '+/' '-_' | tr -d '=')" + [ "${#key}" -ge 32 ] || exit 1 + printf '%s\n' "$key" > "$WRITER_FILE.tmp.$$" && mv -n "$WRITER_FILE.tmp.$$" "$WRITER_FILE" + printf '\n# scoped writer credential (read tools + emr_remember / emr_upsert): generated by jarvisctl keys writer-key-init\nEMR_WRITER_API_KEY=%s\n' "$key" >> "$APP_ENV" ) + rm -f "$WRITER_FILE.tmp.$$"; chmod 600 "$WRITER_FILE" "$APP_ENV" + log INFO "writer key written to $WRITER_FILE and added to secrets/app.env (the value is not shown). Recreate the app to load it: docker compose up -d --force-recreate --no-deps app. Copy the file to the machine that needs it (scp), never paste it." + ;; tenant-create) parse_opts "$@"; [ -z "$tenant$scopes$expires$out$status" ] || bad "tenant-create takes only --label" [ "$have_label" -eq 1 ] && [ -n "$label" ] || bad "tenant-create needs --label NAME" diff --git a/docs/RFC-multi-tenant-ledger.md b/docs/RFC-multi-tenant-ledger.md index 87ff81c..8e1689a 100644 --- a/docs/RFC-multi-tenant-ledger.md +++ b/docs/RFC-multi-tenant-ledger.md @@ -414,6 +414,9 @@ concept at all) confirmed the direction and added three requirements. Both are r handler must be unable to change `current_principal()` for the rest of the request. Also from it: anonymous operation must stay impossible in production: strict mode already refuses to start without a credential; keep that a startup refusal, not a default. +22. **A scoped writer credential (built, 2026-10).** `EMR_WRITER_API_KEY` lets a second machine recall and write governed *drafts* without holding the ledger key (read tools + `emr_remember` / `emr_upsert`, nothing + else; `docs/writer_credential.md`). It narrows what a lost laptop exposes. It does not by itself say *which* credential wrote a record: that is item 13 (the typed credential in the history hash), still unbuilt. + Also noted from mcp-gateway: its role lookup joined on the agent id without asserting the tenant, relying on globally unique ids and the issuer. Our key resolution takes the tenant from the control-schema row of the presented key and never from a request value, which is the stronger rule both studies recommend. diff --git a/docs/recall_credential.md b/docs/recall_credential.md index 00851df..645a9f0 100644 --- a/docs/recall_credential.md +++ b/docs/recall_credential.md @@ -21,3 +21,5 @@ The boundary is pinned by `tests/test_recall_credential_boundary.py`: every rout tools and the MCP transport; the set of tools it may list or call is written out in the test, so adding one is a decision, and a route added later is closed to it by default. Generate a recall key with `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`, put it in `secrets/app.env` as `EMR_RECALL_API_KEY=...`, and give it to the client from a protected file or secret store; never paste it into a profile, a chat or a commit. + +A wider scoped credential exists for clients that also need to write draft memories without the ledger key: see [the writer credential](writer_credential.md). diff --git a/docs/writer_credential.md b/docs/writer_credential.md new file mode 100644 index 0000000..61d2f83 --- /dev/null +++ b/docs/writer_credential.md @@ -0,0 +1,47 @@ +# The writer credential (`EMR_WRITER_API_KEY`) + +`EMR_WRITER_API_KEY` is a **scoped** credential for a client that should recall from the ledger and *write draft memories*, and do nothing else: a second machine, a laptop, another assistant. +It exists so those clients do not need the ledger key (`JARVIS_API_KEY`), which is the full operator credential. It follows [the recall credential](recall_credential.md): same pattern, one step wider. + +| | ledger key | writer key | recall key | +|---|---|---|---| +| the seven read tools (`emr_recall`, `emr_latest`, `emr_search_ledger`, `search`, `fetch`, `emr_search`, `emr_fetch`) over `/api/jarvis/tools/*` and `/mcp` | yes | **yes** | yes | +| `emr_remember`, `emr_upsert` (draft-only; still need `JARVIS_MCP_WRITE_ENABLED=true` **and** `user_requested=true` + a `user_statement`) | yes | **yes** | no (`AUTHORITY_DENIED` over MCP, 401 over REST) | +| `emr_drift_check`, the drift routes, the call log (`/api/jarvis/tools/calls`, `/verify`), the nx routes | yes | **no** (401 / `AUTHORITY_DENIED`) | no | +| every REST ledger route (memory, blocks, attestations, trust, replay, llm, ...) | yes | **no** (401) | no | +| `tools/list` over MCP | every tool | **the nine** above | the seven reads | + +Headers: `Authorization: Bearer ` or `X-EMR-Writer-Key: `. The writer key is **not** accepted in `X-API-Key` (that header is the ledger key's). If several credentials are presented, the +ledger key wins. It is not supported on a public deployment (the recall key is the one credential there), and it is ignored there. + +**What it does not change.** The write gates are the same for every credential: `JARVIS_MCP_WRITE_ENABLED` is the deployment's switch (off by default), a write is a *draft*, and it is refused without +`user_requested=true`. A writer key does not override any of them. It also does not give the server a way to tell machines apart: records still carry a client-chosen `source_agent`, and the call log +records the client name as the client reports it. What it removes is the ledger key from the second machine. + +**Startup checks.** The app refuses to start if `EMR_WRITER_API_KEY` is set and is shorter than 32 characters, equals `JARVIS_API_KEY` or `EMR_RECALL_API_KEY`, or the deployment is public. + +## Installing one (on the box) + +``` +jarvisctl keys writer-key-init # secrets/writer.key (mode 600) + one EMR_WRITER_API_KEY line in secrets/app.env; never overwritten, never printed +cd deploy/mint && docker compose up -d --force-recreate --no-deps app # the app reads the key at start +``` + +Then copy `secrets/writer.key` to the machine that needs it by `scp` (never paste it), and point that machine at it, for example Claude Code on Windows: + +```powershell +$k = (Get-Content "$HOME\.jarvis-writer-key" -Raw).Trim() +claude mcp remove jarvis-ledger --scope user +claude mcp add --transport http --scope user jarvis-ledger http://127.0.0.1:8011/mcp --header "Authorization: Bearer $k" +Remove-Variable k +``` + +The stdio proxy (`mcp_server/emr_stdio.py`) chooses the narrowest key per tool: the recall key for reads, the writer key for `emr_remember` / `emr_upsert`, the ledger key only for the drift check. + +To abandon it: delete the `EMR_WRITER_API_KEY` line from `secrets/app.env`, recreate the app, and delete `secrets/writer.key` (and the copy on the other machine). To rotate: do that, then run `writer-key-init` again. + +## Pinned by + +`tests/test_writer_credential_boundary.py`: every route in the isolation matrix's registry is run with only the writer key and must be refused (401/403) except the read tool routes, the two write tool routes and +the MCP transport; the set of tools it may list or call is written out in the test, so adding one is a decision; the recall key stays read-only with a writer key configured; strict mode binds it as a request +identity; the startup checks. `tests/test_deploy_keys.py` pins `writer-key-init`. diff --git a/mcp_server/emr_stdio.py b/mcp_server/emr_stdio.py index 3c6971d..0bc915d 100644 --- a/mcp_server/emr_stdio.py +++ b/mcp_server/emr_stdio.py @@ -24,6 +24,7 @@ EMR_UPSERT_TOOL, MCP_TOOLS, RECALL_READ_TOOLS, + WRITER_TOOLS, dispatch_rpc, ) @@ -61,8 +62,8 @@ def api_key(tool: str | None = None) -> str: """The ledger API key, or "" when none is configured. Order: EMR_RECALL_API_KEY, JARVIS_API_KEY, then the first line of the file named by JARVIS_API_KEY_FILE. - For a tool OUTSIDE the recall-readable set (the writes, the drift check) the ledger credential comes first when there is one: the recall key is read-only - and the server refuses it there. + For a tool OUTSIDE the recall-readable set the key is chosen by least privilege: emr_remember / emr_upsert use EMR_WRITER_API_KEY when set (then the ledger key), + the drift check needs the ledger key; the recall key is read-only and the server refuses it for any write. A file that is NAMED but missing, unreadable or empty is an error, whatever else is set: the operator pointed at a credential that is not there, so nothing is sent (no request, no key, no empty key). A blank JARVIS_API_KEY_FILE counts as not named. @@ -75,10 +76,15 @@ def api_key(tool: str | None = None) -> str: except KeyFileError as exc: raise RuntimeError(f"JARVIS_API_KEY_FILE names {path!r}, which {exc}. Nothing was sent.") from None recall = (os.environ.get("EMR_RECALL_API_KEY") or "").strip() + writer = (os.environ.get("EMR_WRITER_API_KEY") or "").strip() ledger = (os.environ.get("JARVIS_API_KEY") or "").strip() or from_file - if tool is not None and tool not in RECALL_READ_TOOLS and ledger: + # Least privilege first: a tool a scoped key may call gets the scoped key (the writer key for emr_remember / emr_upsert, the recall key for the reads); the ledger key only + # where nothing narrower will do (or nothing narrower is configured). A tool outside WRITER_TOOLS (the drift check) needs the ledger key. + if tool is not None and tool not in WRITER_TOOLS and ledger: return ledger - return recall or ledger + if tool is not None and tool not in RECALL_READ_TOOLS and tool in WRITER_TOOLS: + return writer or ledger or recall + return recall or writer or ledger # --- who is calling (self-reported; the ledger's call log records it as such) ------------------------------------------------ diff --git a/mcp_server/mcp_http.py b/mcp_server/mcp_http.py index f73e7d4..85d5a9d 100644 --- a/mcp_server/mcp_http.py +++ b/mcp_server/mcp_http.py @@ -21,6 +21,7 @@ MCP_TOOLS, PROTOCOL_VERSION, RECALL_READ_TOOLS, + WRITER_TOOLS, EmrToolCaller, dispatch_rpc, is_jsonrpc_request, @@ -35,23 +36,29 @@ def _require_mcp_auth(request: Request) -> str: request.headers.get("authorization"), request.headers.get("x-emr-recall-key"), request.headers.get("x-api-key"), + request.headers.get("x-emr-writer-key"), ) +_SCOPES = {"recall": (RECALL_READ_TOOLS, "recall-credential-read-only"), "writer": (WRITER_TOOLS, "writer-credential-scope")} + + def _scoped_dispatch(message: dict[str, Any], call_tool: EmrToolCaller, credential: str) -> dict[str, Any] | None: - """dispatch_rpc, narrowed for a recall-scoped credential: it lists, and may call, only RECALL_READ_TOOLS. Anything else is refused as a tool error.""" - if credential != "recall": + """dispatch_rpc, narrowed for a scoped credential: ``recall`` lists, and may call, only RECALL_READ_TOOLS; ``writer`` only WRITER_TOOLS. Anything else is refused as a tool error.""" + scope = _SCOPES.get(credential) + if scope is None: return dispatch_rpc(message, call_tool) + allowed, reason = scope method = message.get("method") if method == "tools/list": - return {"jsonrpc": "2.0", "id": message.get("id"), "result": {"tools": [t for t in MCP_TOOLS if t["name"] in RECALL_READ_TOOLS]}} + return {"jsonrpc": "2.0", "id": message.get("id"), "result": {"tools": [t for t in MCP_TOOLS if t["name"] in allowed]}} if method == "tools/call": params = message.get("params") name = params.get("name") if isinstance(params, dict) else None - if name not in RECALL_READ_TOOLS: + if name not in allowed: return {"jsonrpc": "2.0", "id": message.get("id"), "result": { "content": [{"type": "text", "text": f"tool not permitted for this credential: {name}"}], - "structuredContent": {"error": {"code": "AUTHORITY_DENIED", "reason": "recall-credential-read-only"}}, + "structuredContent": {"error": {"code": "AUTHORITY_DENIED", "reason": reason}}, "isError": True}} return dispatch_rpc(message, call_tool) diff --git a/mcp_server/protocol.py b/mcp_server/protocol.py index 9c66ddc..591faa5 100644 --- a/mcp_server/protocol.py +++ b/mcp_server/protocol.py @@ -369,6 +369,10 @@ # ledger key; adding a tool here is a deliberate decision, pinned by tests/test_recall_credential_boundary.py. RECALL_READ_TOOLS = frozenset({"emr_recall", "emr_latest", "emr_search_ledger", "search", "fetch", "emr_search", "emr_fetch"}) +# The ONLY tools a writer-scoped credential (EMR_WRITER_API_KEY) may list or call: the read tools plus the two governed DRAFT writes. The drift check and anything added later need the +# ledger key; adding a tool here is a deliberate decision, pinned by tests/test_writer_credential_boundary.py. (The writes still need JARVIS_MCP_WRITE_ENABLED and user_requested.) +WRITER_TOOLS = RECALL_READ_TOOLS | frozenset({"emr_remember", "emr_upsert"}) + class ToolRefusal(Exception): """A tool call refused for a reason with a stable machine-readable ``code``. diff --git a/tests/test_deploy_keys.py b/tests/test_deploy_keys.py index b4c0870..a4213b6 100644 --- a/tests/test_deploy_keys.py +++ b/tests/test_deploy_keys.py @@ -154,7 +154,7 @@ def test_status_reports_whether_key_authentication_is_on_and_issuing_a_key_does_ def test_jarvisctl_dispatches_keys_and_lists_it_in_help(box): ctl = str(box.dir / "bin" / "jarvisctl") help_text = subprocess.run([ctl, "help"], env=box.env_, capture_output=True, text=True).stdout - assert "keys pepper-init|pepper-install-app|tenant-create|key-create" in help_text + assert "keys pepper-init|pepper-install-app|writer-key-init|tenant-create|key-create" in help_text r = subprocess.run([ctl, "keys", "nonsense"], env=box.env_, capture_output=True, text=True) assert r.returncode == 2 and "keys.sh" in r.stderr @@ -230,3 +230,38 @@ def test_the_mcp_write_flag_comes_from_the_env_file_and_defaults_to_off(): lines = [l.strip() for l in compose.splitlines() if l.strip().startswith("JARVIS_MCP_WRITE_ENABLED")] assert lines == ["JARVIS_MCP_WRITE_ENABLED: ${JARVIS_MCP_WRITE_ENABLED:-false}"] assert "JARVIS_MCP_WRITE_ENABLED=false" in (MINT / ".env.example").read_text() + + +def test_writer_key_init_makes_a_strong_mode_600_key_adds_one_env_line_and_prints_nothing(box): + (box.secrets / "app.env").write_text("JARVIS_API_KEY=ledger-secret-value\n") + (box.secrets / "app.env").chmod(0o600) + r = box.run("writer-key-init") + assert r.returncode == 0, r.stderr + key = (box.secrets / "writer.key").read_text().strip() + assert len(key) >= 32 and stat.S_IMODE((box.secrets / "writer.key").stat().st_mode) == 0o600 + env = (box.secrets / "app.env").read_text() + assert env.count("EMR_WRITER_API_KEY=") == 1 and f"EMR_WRITER_API_KEY={key}\n" in env and "JARVIS_API_KEY=ledger-secret-value" in env + assert stat.S_IMODE((box.secrets / "app.env").stat().st_mode) == 0o600 + assert key not in r.stdout + r.stderr and "ledger-secret-value" not in r.stdout + r.stderr + assert box.docker_calls() == [] and not list(box.secrets.glob("writer.key.tmp*")) + + +def test_writer_key_init_never_overwrites_and_never_adds_a_second_line(box): + r = box.run("writer-key-init") + assert r.returncode == 1 and "jarvisctl secrets" in r.stderr + (box.secrets / "app.env").write_text("JARVIS_API_KEY=x\n") + assert box.run("writer-key-init").returncode == 0 + first_key, first_env = (box.secrets / "writer.key").read_text(), (box.secrets / "app.env").read_text() + again = box.run("writer-key-init") + assert again.returncode == 1 and "never overwritten" in again.stderr + assert (box.secrets / "writer.key").read_text() == first_key and (box.secrets / "app.env").read_text() == first_env + (box.secrets / "writer.key").unlink() + again = box.run("writer-key-init") + assert again.returncode == 1 and "already sets EMR_WRITER_API_KEY" in again.stderr + assert (box.secrets / "app.env").read_text() == first_env and not (box.secrets / "writer.key").exists() + + +def test_writer_key_init_takes_no_options(box): + (box.secrets / "app.env").write_text("JARVIS_API_KEY=x\n") + assert box.run("writer-key-init", "--force").returncode == 2 + assert not (box.secrets / "writer.key").exists() diff --git a/tests/test_writer_credential_boundary.py b/tests/test_writer_credential_boundary.py new file mode 100644 index 0000000..800eb54 --- /dev/null +++ b/tests/test_writer_credential_boundary.py @@ -0,0 +1,236 @@ +"""EMR_WRITER_API_KEY is a SCOPED credential: the seven read tools plus the two governed DRAFT writes (emr_remember, emr_upsert), and nothing else. + +It exists so a second machine (a laptop, another assistant) can write memories without holding the ledger key. Every route the isolation matrix's registry knows is run with only the writer +key: it must reach exactly the read tool routes, the two write tool routes and the MCP transport, and be refused (401/403) everywhere else; a route added later is closed to it by default. +The writes still need JARVIS_MCP_WRITE_ENABLED and an explicit user_requested, and the recall key stays read-only even with a writer key configured. The ledger key keeps working on all of it. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import secrets + +import pytest +from fastapi.testclient import TestClient + +from app import auth +from app.main import app +from app.store import get_store +from mcp_server.protocol import MCP_TOOLS, RECALL_READ_TOOLS, WRITER_TOOLS +from tests.test_recall_credential_boundary import (ALL_TOOLS, LEDGER, MCP_TRANSPORT, NO_DATA_MCP, READ_TOOL_ROUTES, READ_TOOLS, RECALL, _body, _concrete, _rpc, _tool_args, + world as _recall_world) +from tests.test_tenant_isolation import PUBLIC, REGISTRY + +WRITER = "writer-key-" + secrets.token_urlsafe(24) +WRITE_TOOLS = {"emr_remember", "emr_upsert"} +EXPECTED = READ_TOOLS | WRITE_TOOLS # written out: changing WRITER_TOOLS must be a decision +WRITE_TOOL_ROUTES = {("POST", f"/api/jarvis/tools/{t}") for t in WRITE_TOOLS} +WRITER_FORMS = {"bearer": {"Authorization": f"Bearer {WRITER}"}, "x-emr-writer-key": {"X-EMR-Writer-Key": WRITER}} +USER_OK = {"user_requested": True, "user_statement": "the operator asked for this write"} +REMEMBER = {"content": "WRITER PROBE", "source_agent": "writer-probe", "session_id": "s", "type": "decision", **USER_OK} + + +@pytest.fixture +def world(_recall_world, monkeypatch): + monkeypatch.setenv("EMR_WRITER_API_KEY", WRITER) + return _recall_world + + +def contents(): + return sorted(m.content for m in get_store().list_memories(limit=99)) + + +def test_the_writer_tool_set_is_the_one_written_down(): + assert WRITER_TOOLS == EXPECTED and RECALL_READ_TOOLS == READ_TOOLS + assert WRITER_TOOLS <= {t["name"] for t in MCP_TOOLS} and "emr_drift_check" not in WRITER_TOOLS + + +@pytest.mark.parametrize("form", sorted(WRITER_FORMS)) +def test_every_route_in_the_registry_refuses_the_writer_key_except_the_tools_it_may_use(world, form): + client, _ = world + headers = WRITER_FORMS[form] + answered, wrong = [], [] + for (method, path), (cls, _) in sorted(REGISTRY.items()): + if cls == PUBLIC or (method, path) in READ_TOOL_ROUTES | WRITE_TOOL_ROUTES | MCP_TRANSPORT: + continue + r = client.request(method, _concrete(path), headers=headers, json=_body(method)) + expected = NO_DATA_MCP.get((method, path)) + if expected is not None: + if r.status_code != expected: + wrong.append(f"{method} {path}: {r.status_code} (expected {expected})") + elif r.status_code not in (401, 403): + wrong.append(f"{method} {path}: {r.status_code} {r.text[:80]}") + else: + answered.append((method, path)) + assert not wrong, "the writer key reached routes it must not:\n" + "\n".join(wrong) + assert len(answered) >= 80, "suspiciously few routes were checked: the registry did not load" + + +def test_the_registry_lists_every_route_the_writer_key_may_reach(): + for key in WRITE_TOOL_ROUTES: + assert key in REGISTRY, f"{key} is allowed to the writer key but not in the isolation matrix's registry" + + +@pytest.mark.parametrize("form", sorted(WRITER_FORMS)) +def test_the_writer_key_answers_each_read_tool_route(world, form): + client, mem_id = world + for tool, args in _tool_args(mem_id).items(): + r = client.post(f"/api/jarvis/tools/{tool}", headers=WRITER_FORMS[form], json=args) + assert r.status_code == 200, (tool, r.status_code, r.text[:100]) + + +def test_the_writer_key_writes_drafts_only_when_mcp_writes_are_on_and_only_with_user_intent(world, monkeypatch): + client, _ = world + h = WRITER_FORMS["bearer"] + off = client.post("/api/jarvis/tools/emr_remember", headers=h, json=REMEMBER) + assert off.status_code == 200 and off.json()["refused"] is True and "WRITER PROBE" not in contents() # the flag is the deployment's switch; the writer key does not override it + monkeypatch.setenv("JARVIS_MCP_WRITE_ENABLED", "true") + no_intent = client.post("/api/jarvis/tools/emr_remember", headers=h, json={k: v for k, v in REMEMBER.items() if not k.startswith("user_")}) + assert no_intent.json()["refused"] is True and no_intent.json()["refuse_reason"] == "user-intent-required" and "WRITER PROBE" not in contents() + ok = client.post("/api/jarvis/tools/emr_remember", headers=WRITER_FORMS["x-emr-writer-key"], json=REMEMBER) + assert ok.status_code == 200 and ok.json()["accepted"] is True and ok.json()["memory"]["status"] == "draft" + assert "WRITER PROBE" in contents() + # the record is in the operator's ledger: the ledger key sees it + assert any(m["content"] == "WRITER PROBE" for m in client.get("/api/jarvis/memory", headers={"X-API-Key": LEDGER}).json()["memories"]) + + +@pytest.mark.parametrize("tool", ["emr_drift_check"]) +def test_the_writer_key_is_refused_the_drift_tool_route_even_when_mcp_writes_are_on(world, monkeypatch, tool): + client, mem_id = world + monkeypatch.setenv("JARVIS_MCP_WRITE_ENABLED", "true") + r = client.post(f"/api/jarvis/tools/{tool}", headers=WRITER_FORMS["bearer"], json={"summary": "boundary probe record", "records": [mem_id]}) + assert r.status_code in (401, 403) + assert not list(pathlib.Path(os.environ["JARVIS_DRIFT_LOG_DIR"]).glob("*")), "a writer-only caller wrote to the drift log" + + +@pytest.mark.parametrize("form", sorted(WRITER_FORMS)) +def test_over_mcp_the_writer_key_lists_exactly_its_tools(world, form): + client, _ = world + listed = {t["name"] for t in _rpc(client, WRITER_FORMS[form], "tools/list").json()["result"]["tools"]} + assert listed == EXPECTED, sorted(listed ^ EXPECTED) + assert {t["name"] for t in _rpc(client, {"X-API-Key": LEDGER}, "tools/list").json()["result"]["tools"]} == ALL_TOOLS # the ledger key still lists everything + + +def test_over_mcp_the_writer_key_calls_its_tools_and_is_refused_everything_else(world, monkeypatch): + client, mem_id = world + monkeypatch.setenv("JARVIS_MCP_WRITE_ENABLED", "true") + h = WRITER_FORMS["bearer"] + for i, (tool, args) in enumerate(_tool_args(mem_id).items(), start=1): + assert not _rpc(client, h, "tools/call", {"name": tool, "arguments": args}, i=i).json()["result"].get("isError"), tool + res = _rpc(client, h, "tools/call", {"name": "emr_remember", "arguments": REMEMBER}).json()["result"] + assert not res.get("isError") and json.loads(res["content"][0]["text"])["accepted"] is True and "WRITER PROBE" in contents() + for tool in sorted(ALL_TOOLS - EXPECTED) + ["not_a_tool"]: + res = _rpc(client, h, "tools/call", {"name": tool, "arguments": {"summary": "x", "records": [mem_id]}}).json()["result"] + assert res["isError"] is True and res["structuredContent"]["error"]["reason"] == "writer-credential-scope", (tool, res) + assert not list(pathlib.Path(os.environ["JARVIS_DRIFT_LOG_DIR"]).glob("*")), "a writer-only caller wrote to the drift log" + + +def test_the_writer_key_is_not_accepted_in_x_api_key_and_wrong_or_missing_keys_are_refused(world): + client, _ = world + for headers in ({}, {"X-API-Key": WRITER}, {"Authorization": "Bearer wrong"}, {"X-EMR-Writer-Key": "wrong"}): + assert client.post("/api/jarvis/tools/emr_latest", headers=headers, json={}).status_code == 401, headers + assert client.post("/api/jarvis/tools/emr_remember", headers=headers, json=REMEMBER).status_code == 401, headers + assert _rpc(client, headers, "tools/list").status_code == 401, headers + + +def test_the_recall_key_stays_read_only_even_with_a_writer_key_configured(world, monkeypatch): + client, _ = world + monkeypatch.setenv("JARVIS_MCP_WRITE_ENABLED", "true") + r = client.post("/api/jarvis/tools/emr_remember", headers={"Authorization": f"Bearer {RECALL}"}, json=REMEMBER) + assert r.status_code == 401 and "WRITER PROBE" not in contents() + res = _rpc(client, {"Authorization": f"Bearer {RECALL}"}, "tools/call", {"name": "emr_remember", "arguments": REMEMBER}).json()["result"] + assert res["isError"] is True and res["structuredContent"]["error"]["reason"] == "recall-credential-read-only" and "WRITER PROBE" not in contents() + assert {t["name"] for t in _rpc(client, {"Authorization": f"Bearer {RECALL}"}, "tools/list").json()["result"]["tools"]} == READ_TOOLS + + +def test_the_ledger_key_still_does_everything_and_wins_when_presented_with_the_writer_key(world, monkeypatch): + client, mem_id = world + monkeypatch.setenv("JARVIS_MCP_WRITE_ENABLED", "true") + both = {"X-API-Key": LEDGER, "Authorization": f"Bearer {WRITER}"} + assert client.get("/api/jarvis/tools/calls/verify", headers=both).status_code == 200 + assert client.post("/api/jarvis/drift/check", headers=both, json={"summary": "boundary probe record", "records": [mem_id]}).status_code == 200 + assert {t["name"] for t in _rpc(client, both, "tools/list").json()["result"]["tools"]} == ALL_TOOLS + assert client.post("/api/jarvis/tools/emr_remember", headers={"X-API-Key": LEDGER}, json=REMEMBER).json()["accepted"] is True # as before: installing a writer key breaks no ledger-key client + + +def test_a_writer_key_does_not_open_protected_ledger_reads(world, monkeypatch): + client, _ = world + monkeypatch.setenv("JARVIS_PROTECT_LEDGER_READ", "true") + assert client.get("/api/jarvis/memory", headers={"X-API-Key": LEDGER}).status_code == 200 + for headers in WRITER_FORMS.values(): + assert client.get("/api/jarvis/memory", headers=headers).status_code in (401, 403) + + +# ----------------------------------------------------------------------------------------------------------------- configuration +@pytest.mark.parametrize("value,fragment", [("short", "shorter than"), (LEDGER, "JARVIS_API_KEY"), (RECALL, "EMR_RECALL_API_KEY")]) +def test_a_weak_or_duplicated_writer_key_refuses_to_start(monkeypatch, value, fragment): + monkeypatch.setenv("JARVIS_API_KEY", LEDGER) + monkeypatch.setenv("EMR_RECALL_API_KEY", RECALL) + monkeypatch.setenv("EMR_WRITER_API_KEY", value) + assert any(fragment in p for p in auth.writer_key_problems()) + with pytest.raises(RuntimeError, match="EMR_WRITER_API_KEY refuses to start"): + with TestClient(app): + pass + + +def test_a_good_writer_key_and_no_writer_key_both_start(monkeypatch): + monkeypatch.setenv("JARVIS_API_KEY", LEDGER) + monkeypatch.setenv("EMR_RECALL_API_KEY", RECALL) + monkeypatch.delenv("EMR_WRITER_API_KEY", raising=False) + assert auth.writer_key_problems() == [] + monkeypatch.setenv("EMR_WRITER_API_KEY", WRITER) + assert auth.writer_key_problems() == [] + with TestClient(app): + pass + + +def test_the_writer_key_is_not_a_credential_on_a_public_deployment(world, monkeypatch): + client, _ = world + monkeypatch.setenv("JARVIS_PUBLIC_MODE", "1") + monkeypatch.setenv("JARVIS_TRUSTED_HOSTS", "testserver") + assert any("public deployment" in p for p in auth.writer_key_problems()) + for headers in WRITER_FORMS.values(): + assert client.post("/api/jarvis/tools/emr_latest", headers=headers, json={}).status_code == 401 + + +# ------------------------------------------------------------------------------------------------------------------ stdio proxy +def test_the_stdio_proxy_picks_the_narrowest_key_per_tool(monkeypatch): + from mcp_server import emr_stdio + + monkeypatch.delenv("JARVIS_API_KEY_FILE", raising=False) + monkeypatch.setenv("EMR_RECALL_API_KEY", "the-recall-key") + monkeypatch.setenv("EMR_WRITER_API_KEY", "the-writer-key") + monkeypatch.setenv("JARVIS_API_KEY", "the-ledger-key") + for tool in sorted(READ_TOOLS): + assert emr_stdio.api_key(tool) == "the-recall-key", tool + for tool in sorted(WRITE_TOOLS): + assert emr_stdio.api_key(tool) == "the-writer-key", tool # least privilege: the writer key, not the ledger key + assert emr_stdio.api_key("emr_drift_check") == "the-ledger-key" + monkeypatch.delenv("JARVIS_API_KEY") + monkeypatch.delenv("EMR_RECALL_API_KEY") # a machine that holds ONLY the writer key (the laptop) + for tool in sorted(EXPECTED): + assert emr_stdio.api_key(tool) == "the-writer-key", tool + monkeypatch.delenv("EMR_WRITER_API_KEY") + monkeypatch.setenv("JARVIS_API_KEY", "the-ledger-key") + assert emr_stdio.api_key("emr_remember") == "the-ledger-key" # unchanged without a writer key + + +def test_in_strict_mode_the_writer_key_is_still_a_request_identity_for_its_tools(world, monkeypatch): + """Strict mode (JARVIS_MULTI_TENANT) never treats "no principal" as the operator, so a scoped key only works if the identity layer binds it (as it does the recall key). + Without that binding the writer's tool calls would be refused as unauthenticated even though credential_kind accepts them.""" + client, mem_id = world + monkeypatch.setenv("JARVIS_MULTI_TENANT", "1") + monkeypatch.setenv("JARVIS_MCP_WRITE_ENABLED", "true") + control = client.post("/api/jarvis/tools/emr_latest", headers={"Authorization": f"Bearer {RECALL}"}, json={}) + assert control.status_code == 200, "the recall key (the model for this behaviour) must work in strict mode" + for form, headers in WRITER_FORMS.items(): + assert client.post("/api/jarvis/tools/emr_latest", headers=headers, json={}).status_code == 200, form + assert client.post("/api/jarvis/tools/fetch", headers=headers, json={"id": mem_id}).status_code == 200, form + assert _rpc(client, headers, "tools/list").status_code == 200, form + w = client.post("/api/jarvis/tools/emr_remember", headers=WRITER_FORMS["bearer"], json={**REMEMBER, "content": "WRITER STRICT PROBE"}) + assert w.status_code == 200 and w.json()["accepted"] is True, w.text[:200] + # and it is still only its tools + assert client.get("/api/jarvis/tools/calls/verify", headers=WRITER_FORMS["bearer"]).status_code in (401, 403) + assert client.get("/api/jarvis/memory", headers=WRITER_FORMS["bearer"]).status_code in (401, 403)