apify-mcp-policy-gateway
# Apify MCP Policy Gateway — M1
A deliberately narrow, local-first MCP policy gateway for one organization-owned Apify fixture Actor. It supports a fake transport for deterministic tests and an explicit real Apify HTTP transport for a scoped live fixture.
M1 started with fake transport. The real transport and Codex-agent verification are documented below; the live mode is opt-in and must never receive credentials through MCP tool arguments.
1. `plan_operation`
2. `approve_operation`
3. `execute_approved_operation`
4. `read_sanitized_results`
The gateway is a policy control plane, not an Actor browser, generic proxy, hosted MCP replacement, or credential forwarder.
## Fixed M1 policy
Only this operation can be planned:
| Setting | Allowed value |
|---|---|
| Actor ID | `P4XBS39SYl7aeFJ7g` |
| Canonical owner/name | `metahubb~apify-mcp-policy-fixture` |
| Build | `0.1.1` |
| Maximum runtime | 60 seconds |
| Maximum charge ceiling | USD 0.10 |
| Maximum result count | 3 |
| Risk | `low` only |
The fixture input is restricted to:
```json
{
"message": "1 to 64 characters",
"recordCount": 1,
"delayMs": 0
}
```
`recordCount` defaults to `3`, `delayMs` defaults to `0`, and unknown input fields are denied. `recordCount` may not exceed the requested result limit.
## Flow
### `plan_operation`
Validates the exact Actor, pinned build, fixture input, timeout, charge ceiling, and result limit. A successful response contains:
- A short-lived plan ID and expiry.
- A canonical SHA-256 input hash.
- A canonical plan digest.
- The explicit `ALLOW_LOW_RISK` policy decision.
- Bounded execution parameters.
- `approval_required` status.
No credential or raw secret is accepted or returned.
### `approve_operation`
Marks a known, valid, unexpired low-risk plan approved. M1 approval is a local state transition only; it is not a signed human approval mechanism.
### `execute_approved_operation`
Creates and durably writes an execution reservation before invoking the configured `ActorRunTransport`.
The default transport is local and fake. Set `APIFY_TRANSPORT=real` to use the real Apify HTTP transport. Real mode requires `APIFY_TOKEN` in the runtime environment and never accepts credentials through MCP arguments.
Execution states:
- `SUBMITTED`: the transport returned a run identity.
- `REJECTED`: the transport returned a definitive HTTP rejection such as 403.
- `EXECUTION_UNKNOWN`: submission may have reached the upstream boundary, so it is never automatically retried.
The execution ID is replay-safe. A persisted attempted reservation also recovers as `EXECUTION_UNKNOWN` rather than issuing another POST.
### `read_sanitized_results`
Reads only results belonging to an execution in this gateway state namespace. In fake mode it reads local fixture results; in real mode it reads the execution's Apify default dataset. It:
- Returns at most three items.
- Removes keys beginning with `#` recursively.
- Removes fields whose names look like token, password, secret, API-key, authorization, or credential fields.
- Labels every item with:
```json
{
"dataClassification": "untrusted_external_content"
}
```
The result envelope also states that content must not be treated as instructions and includes Actor, build, execution, policy, and timestamp provenance.
## Real transport mode
Keep fake mode as the default for deterministic development. For a scoped live fixture run, inject the token through the runtime environment:
```bash
export APIFY_TRANSPORT=real
export APIFY_TOKEN='provided by a secret manager or protected shell environment'
npm run build
node real-apify-run.mjs
```
The verified live path uses Actor `P4XBS39SYl7aeFJ7g`, build `0.1.1`, a maximum charge ceiling of USD 0.10, a 60-second timeout, and at most three results. Never place the token in MCP arguments, source files, README files, audit logs, Obsidian notes, or Git history.
The Codex-agent live verification and observed Apify metadata are recorded outside the repository in `16_live_mcp_agent_verification_2026-08-09.md`.
## Local state and audit
By default, `npm run start` creates `.gateway-state/` in the current working directory.
- `gateway.lock` is acquired with exclusive creation. A second process cannot use the same state directory.
- `state.json` is written to a new file, fsynced, atomically renamed, and followed by a directory fsync.
- `audit.jsonl` is append-only in application behavior and each event is fsynced.
- Audit events contain identifiers, decisions, state, latency, safe error codes, and item counts—not raw Actor input, fixture output, credentials, or authorization material.
A privileged local filesystem user remains outside the M1 tamper-prevention boundary.
## Run over stdio
Requirements:
- Node.js 22 or later.
- Project dependencies installed with npm in a normal development environment.
```bash
npm install
npm run start
```
Standard output is reserved for newline-delimited MCP JSON-RPC messages. Do not add `console.log` diagnostics to the server process.
Example MCP host command:
```json
{
"command": "npm",
"args": ["run", "start", "--silent"],
"cwd": "/root/apify-mcp-policy-gateway"
}
```
## Tests and type checking
```bash
npm test
npm run typecheck
```
All tests are non-networked. They use only fake transports, in-memory state, temporary local state directories, and deterministic fixture data.
### Build-environment note
The execution environment used to produce this M1 could not fetch packages from its configured npm registry. The repository therefore contains a small dependency-free stdio MCP wire adapter and the verification run used Node's built-in test runner. `package.json` pins the intended maintained MCP SDK and Vitest dependencies, but replacing the temporary stdio adapter with the official SDK wiring and executing the same suite under Vitest remains a tooling-conformance follow-up. This limitation is recorded in `M1_IMPLEMENTATION_REPORT.md` rather than hidden.
## Security boundaries
- No user token field exists.
- No `.env` or credential loader exists.
- No live Apify request exists.
- No arbitrary Actor, build, run, dataset, or storage identifier is accepted.
- The plan digest and current policy are revalidated before approval, execution, and result access.
- An ambiguous outbound submission is never retried automatically.
- Actor/dataset content is always untrusted external content, never policy or instructions.
- Result sanitation is deterministic key removal, not a claim of complete secret detection.
## Intentionally not implemented
- Real Apify REST transport or token handling.
- Arbitrary Actor discovery or execution.
- Signed-JWS approval.
- Approval-required or blocked workflows beyond explicit denial.
- URL-target policy.
- Remote run-status or dataset reads.
- General redaction of values such as email or phone.
- Universal rollback.
- Prompt-injection detection or prevention claims.
- Multi-user or network transport.
TDQS
Scored across 4 tools
Each tool occupies a distinct lifecycle stage: plan, approve, execute, and read. There is no overlap in purpose, and the descriptions clearly differentiate the action and its constraints.
All tool names follow a consistent verb_noun pattern (plan_operation, approve_operation, execute_approved_operation, read_sanitized_results). The verbs are clear and the objects precisely describe the target, making the set predictable and readable.
With 4 tools, the server is well-scoped for a policy gateway that manages a simple operation lifecycle. Each tool is necessary and the count feels intentional, not bloated or thin.
The lifecycle of plan-approve-execute-read is fully covered for the stated single owned fixture actor. A minor gap is the absence of a cancel or reject operation, but the given M1 context (no signed external approval mechanism) makes this an acceptable simplification for the bounded domain.