Build the isolated AI lab, log the first request, and prove the isolation can fail
Task
Build the environment every later lab in this course runs in: an isolated VM with a small local model bound to loopback, six to ten documents you wrote (one of them restricted and carrying a canary string), a small retrieval app, and a logging gateway that is the only way to the model. Then prove the isolation from inside the VM with a check you have watched fail, and take the baseline snapshot. The 45 minutes is the hands-on time; downloading the model and packages takes as long as your connection does.
Steps
- Create the VM on a NAT or bridged adapter for setup, install the OS, and take a snapshot named
00-bare-os. Create the layout withmkdir -p ~/secai-lab/docs/restricted ~/secai-lab/app/templates ~/secai-lab/gateway ~/secai-lab/logs ~/secai-lab/lab. If your hypervisor supports a disk that snapshots leave alone (VirtualBox calls it write-through, VMware independent-persistent), putlab/andlogs/on one, so reverting after a lab does not discard your results. - While still connected, install everything the course needs, each using its documented method: a model runner (Ollama, or llama.cpp's server), one small open-weight chat model of a few billion parameters at most, one local embedding model, and a Python virtual environment at
~/secai-lab/.venvholding Chroma (or FAISS). If you use sentence-transformers for embeddings, load the model once now, because it downloads on first use. - Still connected, record what the isolation check will probe: run
getent ahostsv4 example.com | awk 'NR==1 {print $1}' > ~/secai-lab/lab/probe-addresses.txt, then append your home router's address and one other device on your real network, one per line (for example192.168.1.1). The file then holds a public address by number without anyone having typed one. - Write the documents in
~/secai-lab/docs/: at leasthr-holiday-policy.md,product-faq.md,oncall-rota.mdandrestricted/salaries.md, plus two to six more (an expenses policy, an IT acceptable-use policy). Start every file with front matter linesclassification:(public,internalorrestricted),owner:anddepartment:. Put the canaryCANARY-7Q4X-SECAIinrestricted/salaries.mdand nowhere else, next to invented salary bands. - Write
~/secai-lab/app/ingest.py: read each document's front matter, split the body into passages of a few sentences, give each chunk the id<path under docs>#<n>(sorestricted/salaries.md#0), embed it with the local embedding model, and store vector, chunk text and metadata (classification,owner,department,source) in~/secai-lab/app/store/. - Write
~/secai-lab/app/rag.pywith two functions.retrieve(question, user)embeds the question and returns the four closest chunks as a list of(chunk_id, text)pairs; it takesusernow so that the access lab can filter on it later.build_prompt(question, chunks)returns the user-role text, with each chunk inside<retrieved id=...>delimiters followed by the question. Keep that assembly in plain sight: it is where injected content lands. Put the assistant's system prompt in~/secai-lab/app/templates/rag-v1.txt. - Write
~/secai-lab/gateway/gateway.py. It acceptsPOST /askwith the body{"user": ..., "question": ...}and does the retrieval itself, so the prompt it logs is the prompt the model received. This version uses only the standard library (plus whateverrag.pyimports) and Ollama's generate endpoint. If you use llama.cpp's server, changeRUNNERand map its reply and token-count fields from its documentation. SetMODELto the model you pulled.
import json, sys, threading, time, urllib.request, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
LAB = Path.home() / "secai-lab"
sys.path.insert(0, str(LAB / "app"))
from rag import retrieve, build_prompt
RUNNER = "http://127.0.0.1:11434/api/generate"
MODEL = "your-small-model"
LOG = LAB / "logs" / "gateway.jsonl"
TEMPLATES = LAB / "app" / "templates"
REFUSAL = "This request was refused by the gateway."
LOCK = threading.Lock()
def check(headers, body, rec):
# Pre-model hook, before retrieval. Later labs add input controls here.
# Return (status, "block:...") to refuse, or None to continue.
return None
def check_prompt(body, rec, chunks):
# Runs once rec["prompt"] is assembled, just before the model is called.
return None
def check_response(body, rec):
# Post-model hook: rec["response"] holds the model's raw output.
# Return (status, "block:...") to withhold it; the log keeps the raw text.
return None
class Gateway(BaseHTTPRequestHandler):
def reply(self, status, obj):
data = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_POST(self):
if self.path != "/ask":
return self.reply(404, {"error": "POST /ask only"})
start = time.time()
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
name = Path(body.get("template", "rag-v1")).name
rec = {"id": uuid.uuid4().hex, "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"user": body.get("user", "anonymous"), "model": MODEL, "template": name,
"system": "", "prompt": "", "retrieved_ids": [], "response": "",
"prompt_tokens": None, "response_tokens": None, "latency_ms": 0,
"decision": "allow"}
refused = check(self.headers, body, rec)
question = rec["prompt"] = body.get("question", "")
tpl = TEMPLATES / (name + ".txt")
if not refused and name != "none" and not tpl.is_file():
refused = (400, "error")
if not refused:
rec["system"] = "" if name == "none" else tpl.read_text(encoding="utf-8")
chunks = retrieve(question, rec["user"]) if body.get("retrieve", True) else []
rec["retrieved_ids"] = [cid for cid, _ in chunks]
rec["prompt"] = build_prompt(question, chunks) if chunks else question
refused = check_prompt(body, rec, chunks)
if not refused:
req = urllib.request.Request(
RUNNER, method="POST", headers={"Content-Type": "application/json"},
data=json.dumps({"model": MODEL, "system": rec["system"],
"prompt": rec["prompt"], "stream": False}).encode())
try:
with urllib.request.urlopen(req, timeout=600) as r:
out = json.load(r)
except OSError as e:
refused = (502, "error")
rec["response"] = "runner error: " + str(e)
else:
rec["response"] = out.get("response", "")
rec["prompt_tokens"] = out.get("prompt_eval_count")
rec["response_tokens"] = out.get("eval_count")
refused = check_response(body, rec)
status = 200
if refused:
status, rec["decision"] = refused
rec["latency_ms"] = round((time.time() - start) * 1000)
with LOCK, LOG.open("a", encoding="utf-8") as f:
print(json.dumps(rec), file=f)
answer = REFUSAL if rec["decision"].startswith("block:") else rec["response"]
self.reply(status, {"response": answer, "decision": rec["decision"],
"retrieved_ids": rec["retrieved_ids"]})
ThreadingHTTPServer(("127.0.0.1", 8088), Gateway).serve_forever()
Two optional body fields serve the evaluation labs: template names a file in app/templates/ (default rag-v1, or none for an empty system prompt), and "retrieve": false skips retrieval. A caller can choose a reviewed template but can never send its own system text. The three empty functions are where every later control goes: check() before retrieval, with the request headers and the log record (so a control can set rec["user"] from a verified key, or rewrite body["question"], which the gateway reads only afterwards); check_prompt() once the prompt is assembled; and check_response(), the post-model hook, after the model replies. A refusal is logged, and the caller gets REFUSAL in place of any answer. Start the gateway in its own terminal with the virtual environment active, and restart it whenever you change rag.py. The user field is whatever the caller claims, and later labs replace it with real identity. 8. Write ~/secai-lab/app/ask.py --user NAME "question", a thin client that POSTs {"user": NAME, "question": ...} to $GW/ask and prints the response and the retrieved_ids. Read the base URL with os.environ.get("GW", "http://127.0.0.1:8088"), so that lesson 13's move to HTTPS on the same port needs no edit. From now on, every question in this course goes through it or straight to the gateway. 9. Make sure the runner listens on loopback only. Ollama listens on 127.0.0.1:11434 by default and its OLLAMA_HOST setting changes that, so check it is not set to an all-interfaces address; llama.cpp's server takes a host argument, which you set to 127.0.0.1. Then run python app/ingest.py and ask three questions as alice, one of them about pay bands. Open logs/gateway.jsonl and find the canary quoted inside a logged prompt: that is the problem lesson 15 solves, and you have just created it. 10. Isolate the VM: switch its adapter to host-only or internal with no default gateway, and turn off shared folders, shared clipboard, drag and drop and every port forwarding rule in the hypervisor's settings for this VM. Record what you checked in ~/secai-lab/lab/hypervisor-settings.csv with header setting,value and the four rows shared_folders, clipboard, drag_and_drop and port_forwarding, each off. 11. Prove the isolation can fail. Run the isolation check in the Verify block while isolated and record the result. Reconnect the NAT or bridged adapter, run it again and watch it report FAIL. Disconnect, run it a third time and see PASS. Record all three in ~/secai-lab/lab/isolation-proof.csv with header run,adapter,result, for example 1,host-only,PASS, 2,nat,FAIL, 3,host-only,PASS. From the host and from another device, also try to reach the VM's ports 8088 and 11434 and confirm neither answers. 12. Take the baseline snapshot, named so you will recognise it in a month (for example secai-baseline-isolated-2026-10-13), and write its name into ~/secai-lab/lab/baseline-snapshot.txt. Repeat the isolation proof whenever you change the VM's network settings.
Verify
cd ~/secai-lab
find docs -name '*.md' | wc -l
grep -rl CANARY-7Q4X-SECAI docs
grep -c -e CANARY-7Q4X-SECAI -e '[[]CANARY[]]' logs/gateway.jsonl
ss -ltn | awk '$4 ~ /:(11434|8088)$/ {n++; if ($4 !~ /^127[.]0[.]0[.]1:/) bad++} END {print n+0" listener(s), "bad+0" not on loopback"}'
curl -s -X POST http://127.0.0.1:8088/ask -H 'Content-Type: application/json' -d '{"user":"verify","question":"How many days of holiday do staff get?"}' | python -c 'import json, sys; r = json.load(sys.stdin); print("gateway decision:", r["decision"], "- chunks retrieved:", len(r["retrieved_ids"]))'
python - <<'EOF'
import json, os
need = {"ts", "user", "prompt", "retrieved_ids", "response", "prompt_tokens", "response_tokens", "latency_ms", "decision"}
rows = [json.loads(l) for l in open(os.path.expanduser("~/secai-lab/logs/gateway.jsonl"), encoding="utf-8") if l.strip()]
short = [i for i, r in enumerate(rows, 1) if not need <= set(r)]
print(len(rows), "log record(s),", len(short), "missing a field:", "PASS" if rows and not short else "FAIL")
EOF
python - <<'EOF'
import os, socket
fails = []
with open("/proc/net/route") as f:
next(f)
if any(line.split()[1] == "00000000" for line in f):
fails.append("a default route exists")
try:
socket.getaddrinfo("example.com", 443)
fails.append("example.com resolved")
except socket.gaierror:
pass
for ip in open(os.path.expanduser("~/secai-lab/lab/probe-addresses.txt")).read().split():
s = socket.socket()
s.settimeout(5)
try:
s.connect((ip, 80))
fails.append(ip + " answered")
except ConnectionRefusedError:
fails.append(ip + " refused, so something on the path answered")
except OSError:
pass
finally:
s.close()
for f in fails:
print("FAIL:", f)
print("isolation:", "PASS" if not fails else "FAIL")
EOF
awk -F, 'NR>1 && $3=="FAIL" && $2!~/host-only|internal/ {f=NR} NR>1 && $3=="PASS" && $2~/host-only|internal/ {p=NR} END {print ((f && p>f) ? "proof PASS: seen to fail, then pass" : "proof FAIL")}' lab/isolation-proof.csv
awk -F, 'NR>1 && $2=="off" {n++} END {print n+0" of 4 hypervisor paths off"}' lab/hypervisor-settings.csv
Six to ten documents; exactly one path, docs/restricted/salaries.md, holding the canary; a canary count in the log of at least one; two listeners with none off loopback; gateway decision: allow with four chunks retrieved; every log record carrying all nine fields; isolation: PASS while isolated; proof PASS; and four of four hypervisor paths off. Run the isolation check with the adapter reconnected and it must print FAIL lines. A "connection refused" is deliberately a failure, because a refusal means something on the path answered.
Notes
Later labs put every control in one of the three hooks and rely on these status codes and decision values, which are the only ones any lab in the course uses, so keep the shape of the gateway and its reply (response, decision, retrieved_ids): allow; redact (lesson 14: an allowed request whose question had sensitive values removed); error (400 for an unknown template name, 502 when the runner cannot be reached); 401 block:auth; 413 block:size and block:input-tokens; 429 block:rate and block:budget; 415 block:modality; 400 block:firewall (lesson 10's input firewall, and lesson 17's quarantine of a retrieved chunk); 200 with block:canary (an answer withheld after the model replied); 503 block:guardrail-error. A refused request is still logged, which is how the monitoring labs count refusals. Later labs add log fields as well: prompt_sha256 (lesson 15), session, model_id, template_version, params and flags (lesson 15's applied lab) and quarantined_ids (lesson 17).
Four conventions hold for the rest of the course. The users are alice and bob (staff), carol (HR, the one reader of docs/restricted/), dave (the on-call lead) and visitor, who is in no file; test traffic gets names of its own, such as tester, so it never pollutes a baseline. From lesson 10, each user may send five requests in ten minutes unless gateway/rules.json gives that user a figure of its own, so a lab that sends a batch as one user adds that user there first. From lesson 13, the gateway speaks HTTPS on the same port and $GW holds its base URL. From lesson 15, the log writer masks the canary as [CANARY], so every leak count over the log matches either form, CANARY-7Q4X-SECAI or [CANARY] (with grep, -e CANARY-7Q4X-SECAI -e '[[]CANARY[]]'), and never the bare word CANARY, which lesson 17's planted notice also carries.
The isolation check probes port 80 on each address because a home router almost always answers there, either by accepting the connection or by refusing it, and both count as reachable. If your host-only network uses the same subnet as your home network, the router test is meaningless; give the host-only network a different RFC1918 range. /proc/net/route covers IPv4; also run ip -6 route show default and expect it to print nothing. Some libraries try to reach the network on first use, which inside this VM shows up as a timeout. That is the isolation working: reconnect deliberately, fetch what is needed, disconnect, and rerun the proof.
This is an independent study companion for CompTIA SecAI+ CY0-001 and is not produced by or endorsed by CompTIA.