# My lunch-break response to ARTEX: a host intrusion sentinel

> Source: <https://github.com/gary23w/agent-garrett-sentinel-service>
> Published: 2026-11-14 21:30:57+00:00

A host intrusion sentinel you run yourself. A small agent on each machine watches running programs and their network connections, scores what it sees in code, and reports the cases that look like an attack to a service you deploy in your own Cloudflare account. By default it also ends a hostile program, and everything that program started, on the spot.

It was built in response to FBI FLASH-20261113-001 on agentic intrusion frameworks, and it is aimed at the things those tools do on a machine: web shells, loaders, remote-access implants, movement between Windows machines, Active Directory attacks, and the pace and repetition of an automated operator.

**Status: draft, not yet released.** Read "What has and has not been tested" before relying on it.

```
 each machine                                   your Cloudflare account
┌──────────────────────────────┐   HTTPS out   ┌─────────────────────────────────┐
│ agent/sentinel_agent.py      │ ────────────► │ server/src/worker.mjs  (Worker) │
│  sensors  → scoring → cases  │ ◄──────────── │  cases, decisions, learning     │
│  pause / stop (if approved)  │  decisions,   │  D1 database, optional AI       │
└──────────────────────────────┘  weights      └─────────────────────────────────┘
```

- **The agent** reads the operating system's own process and connection tables. It captures no packets and
reads no file contents. It opens no port. Scoring happens on the machine.
- **Only cases leave the machine** : a program that reached the SUSPECT or HOSTILE level, with the signals
that fired, the programs that started it, and the addresses it talked to. A command line never leaves the
machine; the agent reports which pattern matched, not the text.
- **The service** checks every field, scores the signals again (the level is never taken from the device),
stores the case encrypted, raises an alert, and hands back two things: the operator's decision on a program
that agent paused, and the learned signal weights.
- **It acts, within limits.** It never touches operating-system processes, security software, container
runtimes, remote access (`sshd` ), the web server itself, or the browser, mail or document program a person
is working in: it ends the harmful thing those programs started, not the programs. It never signals a
process that has already exited.

| Mode | What the agent does about a hostile program | Choose it when | 
|---|---|---|
| `kill` | ends it and everything it started, at once, then reports what it did | an intruder's minutes matter more than a wrongly stopped job | 
| `decide` | freezes it at once, then the service's decision model says stop, release or hold | you have a model and want a false alarm to cost seconds, not a program | 
| `pause` | freezes it (reversible) and waits for an operator to stop or release it | a wrong stop would be an outage | 
| `report` | nothing; it only reports | you are evaluating, or another tool responds | 

Set the ceiling for the whole deployment with the service variable `RESPONSE_MODE`, and a machine's own mode
with `--mode` at enrolment. A machine gets the lower of the two.

**The default.** With `RESPONSE_MODE` left empty, a deployment with a model configured runs `decide`, and one
without runs `kill`. Agents ask for `kill` unless told otherwise.

**How `decide` works.** The agent freezes the program the moment it is judged hostile, so the attack is already
stopped while the decision is made. The service asks the model once, with an eight-second limit, and answers in
the same exchange:

- **stop** when the model recommends it with high confidence: the agent ends the program.
- **release** only if you set`DECISION_RELEASE` to`high-confidence` , the model is highly confident, and no
locked signal is involved (a published indicator, credential theft, a directory attack). The agent lets it
run and leaves it alone afterwards. A model's release does not mark the program as yours and teaches nothing.
- **hold** in every other case: it stays frozen until an operator decides.

If the model fails or times out, `DECISION_FALLBACK` decides: `kill` (the default) or `hold`. An agent that
hears nothing from the service at all for twenty seconds applies the same fallback by itself.

**Kill is not reversible.** In `kill` mode a false alarm ends a legitimate program; `decide` exists to avoid
exactly that. The agent acts only on
specific launch chains with almost no honest use, or on cases whose evidence spans three categories, and a
program an operator marks "ours" is never touched again. Even so: run `pause` or `report` first on machines
where a wrong stop is costly, and watch what it would have done.

**If you run `report` or `pause`, run the AI analyst as well** (see "The AI part"). In those modes nothing ends
an attack until a person reads the case, and an automated intruder works in minutes. A second, independent
model reading every case and recommending stop or release shortens that wait; keep it separate from whatever
AI your organisation already uses, so one compromised or confused system is not judging its own work.

| Area | Signals | 
|---|---|
| Email and lures | scripts started by mail, document and browser programs; programs run out of attachments and archives; shortcut and paste-this-command launches | 
| Web applications | a shell or interpreter started by a web server; download-and-run and reverse-shell commands; who-am-I commands | 
| Loaders and implants | system tools used as loaders (MSBuild, cmstp, regsvr32, rundll32, mshta); programs in temporary folders talking to the internet; regular call-home timing; fan-out scanning | 
| Windows domains | credential dumping and Kerberoasting commands; domain enumeration; shells inside WinRM, WMI and PsExec sessions; and, when the agent runs with administrator rights, Security-log patterns for Kerberoasting, DCSync, failed sign-in bursts, cleared logs and privileged group changes | 
| Automated operators | a stream of different commands at machine pace under one foothold; near-copy retries; the same command started twice at once | 
| Web requests | read from the web server's own access log (nginx, Apache, IIS): directory fuzzing, injection-shaped requests and whether the application answered them, walking through record numbers, sign-in guessing, calls to a server-side script with a command parameter, and bursts of application failures on requests that carry a body | 
| Web folders | a server-side script that appears in a web folder after the agent started, with no release around it | 
| Tunnels | Chisel by name; SoftEther by its program names with its install folder or its ports; Tor by its client, by any program sending traffic through the local Tor proxy, and by inbound connections from the Tor Project's published exit nodes | 
| Attempts from outside | bursts on SSH, Remote Desktop, WinRM, SMB, VNC and Telnet | 
| Published indicators | the addresses and domain listed in FBI FLASH-20261113-001, as a scored signal with the FBI's caveat that addresses get reassigned; never an automatic block | 
| Antivirus | new Windows Defender detections become cases | 
| Cloud credentials | a web server process reaching for the cloud metadata service, where instance credentials are handed out | 
| Cloud control planes | from audit records you forward: credentials added to applications and service principals, ownership, role and consent grants, weakened Conditional Access and federation settings (Microsoft Entra); permission mapping by dry-run, key pairs, public addresses, large volumes, role-bearing instances, access keys made for other users, administrator policies (AWS CloudTrail); validation dry-runs, publishing credentials and storage keys (Azure activity log); and any of it from an address in the FLASH | 

Signals are weighted and grouped into categories. A case is called HOSTILE only when the evidence spans at least three categories, so no single rule decides it.

Needs Node.js 22.5 or newer and Python 3.9 or newer.

``` python
cd server
export SENTINEL_ENROLL_KEY=$(python3 -c "import secrets;print(secrets.token_hex(24))")
export SENTINEL_ADMIN_TOKEN=$(python3 -c "import secrets;print(secrets.token_hex(24))")
node local.mjs            # in-memory database, for evaluation only
```

In another terminal, with the same two variables set:

```
python3 agent/sentinel_agent.py --state-dir ./state enroll --server http://127.0.0.1:8787
python3 agent/sentinel_agent.py --state-dir ./state run
curl -H "Authorization: Bearer $SENTINEL_ADMIN_TOKEN" http://127.0.0.1:8787/v1/devices
curl -H "Authorization: Bearer $SENTINEL_ADMIN_TOKEN" "http://127.0.0.1:8787/v1/cases?state=open"
```

Copy `server/wrangler.example.jsonc` to `server/wrangler.jsonc`, then follow the commands at the top of that
file: create the D1 database, load `schema.sql`, set the three secrets, deploy. Three secrets are required and
each must be at least 32 characters:

| Secret | What it is for | 
|---|---|
| `DATA_KEY` | encrypts case evidence at rest (AES-256-GCM) | 
| `ENROLL_KEY` | lets a machine enrol; share it only with the people who install agents | 
| `ADMIN_TOKEN` | lets an operator read cases and make decisions | 

Optional: `ALERT_WEBHOOK_URL` receives a JSON alert for each new case. The alert text is written by the
service from the signal ids; text from a device is never placed in an alert.

| Where | How | 
|---|---|
| Linux (systemd) | `sudo SENTINEL_ENROLL_KEY=<key> deploy/install-linux.sh https://your-service [kill\|pause\|report]` | 
| Windows (as SYSTEM) | elevated PowerShell: `deploy\install-windows.ps1 -Server https://your-service -EnrollKey <key> [-Mode kill\|pause\|report]` | 
| Kubernetes | build `deploy/Dockerfile` , fill in the Secret in`deploy/kubernetes-daemonset.yaml` , apply it; every node enrols itself | 
| Anything else | `python3 agent/sentinel_agent.py enroll --server ... --key ...` then`... run` under your own supervisor | 

Run the agent with administrator or root rights where you can. Without them it sees only the current user's programs, and on Windows it cannot read the Security log.

Every installer enrols in `kill` mode unless you name another.

What an intruder does in Microsoft Entra, Azure or AWS leaves nothing on a machine; it is all in the audit logs. Forward those records to the service and it applies the cloud rules:

```
curl -X POST https://your-service/v1/cloud-events \
  -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
  -d '{"source": "aws-cloudtrail", "records": [ ...CloudTrail records... ]}'
```

`source` is `entra-audit`, `entra-signin`, `aws-cloudtrail` or `azure-activity`; send up to 1000 records at a
time, exactly as the cloud wrote them. Use whatever already moves your logs (an Event Hub or EventBridge
target, a scheduled export, your SIEM's forwarder). The service never calls a cloud API and holds no cloud
credential. Each identity whose records add up to a case gets one, under a stand-in device named after the
source; an operator can mark an identity as theirs so it is not raised again.

Each batch is judged on its own, so forward records in batches that cover a few minutes. The rules are written from the vendors' published log references and tested with records in those shapes; they have not been run against a live tenant.

Detection, scoring, stopping, pausing and learning are all plain code. They need no model and work with
`AI_PROVIDER` set to `none`, which is the default.

One optional model does two jobs, both in `server/src/analyst.mjs`:

- **Analyst.** It adds a second opinion to every case for the operator: a two-sentence summary, the likely
technique, and a recommendation.
- **Decision model.** In`decide` mode the same answer settles what happens to a frozen program, within the
limits under "How`decide` works".

The model never changes a score and never teaches the policy. It sees signal ids with the service's own sentences, program names and counts; it is never sent a command line, and it is told that everything in the case is data. Choose it with one variable:

| `AI_PROVIDER` | What it uses | Settings | 
|---|---|---|
| `none` | no model |  | 
| `workers-ai` | Cloudflare Workers AI through the `AI` binding | `AI_MODEL` | 
| `openai` | any OpenAI-compatible chat-completions endpoint, hosted or your own | `AI_BASE_URL` ,`AI_MODEL` , secret`AI_API_KEY` | 
| `custom` | your own analyst: a service binding named `ANALYST` , or a URL in`AI_BASE_URL` | receives `{instructions, case}` as JSON, answers`{summary, technique, recommendation, confidence}` | 

To plug in a different model, add a branch in that file.

**What one decision model covers.** Every case the agent can freeze passes through the same model: email and
document launches, web shells, loaders, implants, credential theft and movement between Windows machines. It
does not decide cases where there is nothing on the machine to stop: web-request attacks, connection bursts,
directory-log patterns and antivirus detections are analysed for the operator, not acted on. And a model adds
judgement, not sight: it covers only what the sensors report and what you forward.

The service learns from your operators' own verdicts and nothing else:

- Each case an operator marks "ours" or "an attack" is remembered as its shape: the signal ids, the platform and the verdict. No program name, path, address or command line is kept in that memory.
- A signal your operators keep dismissing becomes worth less on your machines; one they keep confirming becomes worth more. A weight moves one point at a time and at most two points in total.
- Published indicators, antivirus verdicts, attacks on a service and directory attacks never move.
- A change is adopted only if every attack your operators confirmed before, and every reference attack in
`server/src/signals.mjs` , would still be reported under it. Otherwise it is rejected.
- `POST /v1/learning/reset` forgets everything learned.

No model takes part in this. It is bounded tuning, not a system that rewrites itself.

**Plugging in a memory engine.** Recall of similar past cases works out of the box by comparing shapes. If
you have a semantic memory store, bind an object as `SENTINEL_MEMORY` with `remember(text)` and
`recall(query, k)`; the service will write each verdict to it and show what it recalls beside a new case.

All operator calls need `Authorization: Bearer <ADMIN_TOKEN>`.

| Call | Purpose | 
|---|---|
| `GET /v1/devices` ,`POST /v1/devices/:id/revoke` | list machines, remove one | 
| `GET /v1/cases?state=open` ,`GET /v1/cases/:id` | list cases, read one with its evidence, chain and similar past verdicts | 
| `POST /v1/cases/:id/action` | `{"action": "trust" \| "confirm" \| "resolve" \| "reopen" \| "stop" \| "release"}` | 
| `POST /v1/cases/:id/analyze` | ask the AI analyst again | 
| `GET /v1/learning` ,`POST /v1/learning/reset` | see or forget what was learned | 
| `POST /v1/cloud-events` | forward cloud audit records (accepts the separate `INGEST_TOKEN` as well) | 

There is no web dashboard in this repository yet; the API is the interface.

Measured on the development machines; your numbers will differ.

| What | Measured | 
|---|---|
| Time from a staged document-launched script starting to it being ended (Windows, `kill` mode) | within 1.4 seconds | 
| The same in `decide` mode with a stand-in model | frozen after 0.1 seconds, ended on the model's "stop" within 1 second | 
| A stand-in false alarm in `decide` mode (model says release, release allowed) | frozen briefly, then left running | 
| Time to pause a staged web shell (Linux) | within one 5-second sample; later runs judge new programs as they start | 
| Cost of one full sample | about 6 ms on Windows, about 15 ms on Linux | 
| Cost of one look for new programs (five a second) | about 3 ms on Windows | 
| Traffic when quiet | one small report per machine every five minutes | 
| Model calls | none, unless the AI analyst is on: then one per new case | 

```
cd agent  && python3 -m unittest discover -s tests -p "test_*.py"   # engine, pause/stop, 100 scripted events
cd agent  && python3 tests/test_cases.py                            # the per-event table
cd server && npm test                                               # the service
```

MIT. See [LICENSE](https://github.com/gary23w/agent-garrett-sentinel-service/blob/main/LICENSE).
