End-to-End Agentic Security Workflow with CodeMender

1. Introduction

In this codelab you will put the previous two codelabs together. You will give Antigravity a feature request, then watch it plan with a threat model, write failing tests, implement the feature, and push. On push, the security gate from the previous codelab runs CodeMender against the changed files. Because the sample application still has an open command injection in the file the agent is editing, the first push gets blocked, and you will see the agent clear the finding test-first and push again, with no human prompts from CodeMender along the way.

What you will learn

  • How to run CodeMender headlessly so neither the hook nor the agent blocks on [Y/n] prompts.
  • What the Plan → Red → Green → Refactor loop produces when the agent builds a feature: threat_model.md, tests, code, and a commit.
  • How the security gate responds when a pushed file has open findings, and how the agent uses the security-test-writer and defensive-developer skills to clear them.
  • How to read the audit trail the gate leaves behind: .security-gate/findings-log.ndjson, auto-evolved conventions in CONTEXT.md, and git history.

Prerequisites

  • The ~/codemender-lab workspace from the previous two codelabs, with a clean git working tree on branch main.
  • Antigravity installed, with ~/codemender-lab available to open as a folder.
  • git, jq, and the Python virtual environment with Flask installed.

2. Verifying the Setup From the Previous Codelabs

Step 1: Return to the lab directory

cd ~/codemender-lab && source .venv/bin/activate

Step 2: Confirm the framework files are in place

ls .agents/hooks.json .agents/security_gate_hook.sh .agents/rules/secure_tdd_workflow.md
ls .agents/skills
ls tests
git status --short && git branch --show-current

You should see the six skill directories (defensive-developer, history-context-seeder, local-refactor-scanner, security-test-writer, skill-evolution-updater, threat-model-assessor), tests/__init__.py and tests/test_data_service.py, an empty git status, and main.

Run the offline hook tests one more time to confirm nothing has drifted:

bash .agents/tests/run_tests.sh | tail -1
60 passed, 0 failed

Step 3: Confirm the CodeMender environment

cm init --verify

It should report git as the VCS type and python3 -m compileall -q . as the build command, as configured in CodeMender CLI Basics.

Step 4: Clear stale findings

cm clean

Type y when prompted. This empties ~/.codemender/state.db.

Step 5: Create a remote to push to

The hook compares your working tree with origin/main, and git push needs somewhere to go. A bare repository on the same machine is enough:

git init --bare ~/codemender-remote.git
git remote add origin ~/codemender-remote.git
git push -u origin main

This push happens in your own terminal, so the Antigravity hook does not see it. From here on, the agent makes the pushes.

3. Running CodeMender Headless

Step 1: Turn off confirmation prompts

By default, CodeMender asks [Y/n] before every command it runs and every file it writes. Inside a hook, nobody is there to answer. Open ~/.codemender/config.yaml and set both tools flags to false, or run:

sed -i.bak 's/^  confirm_commands: true/  confirm_commands: false/; s/^  confirm_writes: true/  confirm_writes: false/' ~/.codemender/config.yaml
rm ~/.codemender/config.yaml.bak

The resulting section should read:

tools:
  confirm_commands: false
  confirm_writes: false

Step 2: Verify

cm init --verify

4. The Feature Request

Step 1: Open the workspace

Open ~/codemender-lab as a folder in Antigravity and start a new conversation. The always-on secure_tdd_workflow rule is now part of every turn, and the six skills are available.

Step 2: Give the agent the task

Paste the following prompt. Notice that it says nothing about security. Steering the agent toward threat modeling, test-first development, and defensive patterns is the job of the rule and skills, not the prompt:

Add a POST /update_profile endpoint to sample_app/data_service.py that lets a user update their email address. It accepts user_id and email as form fields. When the change is complete and the test suite passes, commit it and push to origin main.

Step 3: Approve the agent's commands

The hook runs outside the tool-permission system, but the agent's own commands do not. Expect prompts for python3 -m unittest ..., git add/commit/push, and any cm command it decides to run. Approve them; if Antigravity offers to remember the decision, accept it so the fix loop later in this codelab does not stall on repeated prompts.

5. What the Agent Does: Plan, Red, Green, Refactor

What follows is a representative run. Your agent's wording, file names, and exact code will differ, and that is fine. What should not differ is the order: a threat model before tests, failing tests before code, a green suite before the commit.

Phase A: Plan and threat model

The agent reads CONTEXT.md, loads threat-model-assessor, and writes threat_model.md at the workspace root:

# Feature Plan & Threat Model: Update Profile Email

## 1. Functional Scope & Requirements
- **Goal**: Allow a user to change the email address stored for their account.
- **Deliverables**: `POST /update_profile` in `sample_app/data_service.py`; tests in `tests/test_update_profile.py`.
- **Functional Acceptance Criteria**: Valid `user_id` + `email` returns 200 and the row is updated; unknown `user_id` returns 404.

## 2. Entry Points & Data Inputs
- Endpoint / Input: `POST /update_profile`, form fields `user_id`, `email`
- Format & Constraints: `user_id` positive integer; `email` <= 254 chars, single `@`, non-empty local and domain parts.

## 3. Trust Boundaries & Access Controls
- Authentication: None exists in this sample. Recorded as a known gap; out of scope for this change.
- Authorization Role: N/A (see above).

## 4. STRIDE Threat Matrix & Security Acceptance Criteria
- Tampering: SQL injection through `user_id`/`email` -> parameterized `UPDATE`; test that `"1 OR 1=1"` returns 400.
- Tampering: Malformed email stored -> strict validation; test that `"not-an-email"` returns 400.
- Information Disclosure: Stack traces on bad input -> return JSON error bodies only.
- Denial of Service: Oversized email -> length cap at 254; test returns 400.

Phase B: Failing tests (RED)

The agent loads security-test-writer and creates tests/test_update_profile.py. A typical version uses a throwaway users.db in the working directory, which is why the previous codelab added users.db to .gitignore:

import os
import sqlite3
import unittest

from sample_app.data_service import app


class UpdateProfileTests(unittest.TestCase):
    def setUp(self):
        conn = sqlite3.connect('users.db')
        conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, email TEXT)")
        conn.execute("INSERT OR REPLACE INTO users (id, username, email) VALUES (1, 'alice', 'old@example.com')")
        conn.commit()
        conn.close()
        self.client = app.test_client()

    def tearDown(self):
        os.remove('users.db')

    def test_updates_email_for_valid_request(self):
        r = self.client.post('/update_profile', data={'user_id': '1', 'email': 'new@example.com'})
        self.assertEqual(r.status_code, 200)

    def test_rejects_non_numeric_user_id(self):
        r = self.client.post('/update_profile', data={'user_id': '1 OR 1=1', 'email': 'new@example.com'})
        self.assertEqual(r.status_code, 400)

    def test_rejects_malformed_email(self):
        r = self.client.post('/update_profile', data={'user_id': '1', 'email': 'not-an-email'})
        self.assertEqual(r.status_code, 400)

    def test_rejects_oversized_email(self):
        r = self.client.post('/update_profile', data={'user_id': '1', 'email': 'a' * 250 + '@x.io'})
        self.assertEqual(r.status_code, 400)

The agent runs python3 -m unittest discover -s tests and confirms the new tests fail with 404 != 200 and 404 != 400, because the route does not exist yet. That is the right reason to fail. A failure caused by an import error or a typo is not RED; it is a broken test.

Phase C: Minimal implementation (GREEN)

The agent loads defensive-developer and adds the endpoint. It uses a parameterized query because CONTEXT.md names that as the required pattern for SQL:

import re

EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


@app.route('/update_profile', methods=['POST'])
def update_profile():
    user_id = request.form.get('user_id', '')
    email = request.form.get('email', '')
    if not user_id.isdigit():
        return jsonify({"error": "user_id must be a positive integer"}), 400
    if len(email) > 254 or not EMAIL_PATTERN.match(email):
        return jsonify({"error": "invalid email address"}), 400
    conn = sqlite3.connect('users.db')
    try:
        cursor = conn.execute("UPDATE users SET email = ? WHERE id = ?", (email, int(user_id)))
        conn.commit()
    finally:
        conn.close()
    if cursor.rowcount == 0:
        return jsonify({"error": "user not found"}), 404
    return jsonify({"status": "updated", "user_id": int(user_id)}), 200

The suite now passes: the four new tests plus the characterization test from the previous codelab.

Phase D: Refactor, scan, commit

The agent loads local-refactor-scanner, reviews git diff, re-runs the full suite, and commits:

git add sample_app/data_service.py tests/test_update_profile.py threat_model.md
git commit -m "feat: add POST /update_profile with validated input and parameterized update"

6. The Security Gate on git push

The first push is denied

The agent runs git push origin main. Antigravity matches the run_command tool against hooks.json and runs security_gate_hook.sh, which recognizes the push and scans the changed files: sample_app/data_service.py, tests/test_update_profile.py, and threat_model.md (CodeMender skips .md files; cm find on that path finds no source files and the hook moves on).

This takes a few minutes. The hook is synchronous, so the agent's turn appears to hang on the push command while cm find and cm report run. When the scan finishes, the gate finds the command injection in /ping that CodeMender CLI Basics never fixed. It is CRITICAL, it is OPEN, and it is in a file being pushed, so the hook denies:

Stage 2: Running Semantic Analysis & Verification (CodeMender)...
Detected 1 blocking vulnerabilit(y/ies). Evaluating attempt budget per finding...
Finding 80bdddd6-... (CRITICAL) in /home/you/codemender-lab/sample_app/data_service.py: Attempt 1/3
{"allow_tool": false, "decision": "deny", "reason": "Security Gate blocked push (1 unresolved finding(s)).

Action Required: Execute the Secure TDD Loop sequentially for each finding:
For each finding below (one at a time):
  1. Invoke 'security-test-writer' to author one failing boundary test in tests/ (assert RED).
  2. Invoke 'defensive-developer' to apply the minimal defensive fix (assert GREEN).
  3. Run the local test to confirm it passes before moving to the next finding.
Once all findings are resolved, run the full regression test suite (python3 -m unittest discover -s tests) and retry 'git push'.

Open Findings:
- [80bdddd6-...] (CRITICAL) in /home/you/codemender-lab/sample_app/data_service.py (Attempt 1/3): OS Command Injection in ping_host endpoint", ...}

The push did not run. The agent sees the reason text as the result of its tool call, and the gate has logged a DENIED_TO_AGENT event for this finding.

The agent clears the finding test-first

The rejection names the skills to use, and rule item 6 (Multi-Finding Remediation Protocol) says to take findings one at a time. For the /ping finding, a representative run looks like this.

A failing boundary test in tests/test_ping.py:

import unittest

from sample_app.data_service import app


class PingBoundaryTests(unittest.TestCase):
    def setUp(self):
        self.client = app.test_client()

    def test_rejects_shell_metacharacters(self):
        r = self.client.get('/ping', query_string={'ip': '127.0.0.1; id'})
        self.assertEqual(r.status_code, 400)

    def test_rejects_hostname_that_is_not_an_ip(self):
        r = self.client.get('/ping', query_string={'ip': 'example.com'})
        self.assertEqual(r.status_code, 400)

The minimal fix, replacing os.system with a list-form subprocess.run as CONTEXT.md requires, plus an allow-list parser instead of string checks:

import ipaddress
import subprocess


@app.route('/ping')
def ping_host():
    ip = request.args.get('ip', '')
    try:
        target = str(ipaddress.ip_address(ip))
    except ValueError:
        return jsonify({"error": "ip must be a valid IP address"}), 400
    subprocess.run(["ping", "-c", "1", target], shell=False, check=False, timeout=5)
    return "Ping initiated"

The agent runs the full suite, commits with a message describing the finding and the fix, and pushes again.

The second push is allowed

The gate re-scans sample_app/data_service.py. The command injection is gone. No blocking findings remain, so the gate checks its own log, sees that the finding it denied earlier is no longer present, and records the outcome before allowing the push:

Stage 2: Running Semantic Analysis & Verification (CodeMender)...
No blocking vulnerabilities detected. Code verified clean.
Updated CONTEXT.md with auto-evolved convention for 80bdddd6-....
Continuous Evolution Suggestion: Consider updating relevant SKILL.md files (e.g. security-test-writer / defensive-developer) to encode CRITICAL.
{"allow_tool": true, "decision": "allow"}

git push origin main runs, and the remote now has three new commits: the feature, the fix, and a docs(context) commit the hook made itself.

7. Reviewing the Audit Trail

Everything the gate decided is on disk in plain text. From a terminal in ~/codemender-lab:

The gate log

jq -c '{ts, event, tool, finding_id, file, attempt}' .security-gate/findings-log.ndjson
{"ts":"2026-11-13T22:41:07Z","event":"DENIED_TO_AGENT","tool":"codemender","finding_id":"80bdddd6-...","file":"/home/you/codemender-lab/sample_app/data_service.py","attempt":1}
{"ts":"2026-11-13T22:58:32Z","event":"FIXED","tool":"codemender","finding_id":"80bdddd6-...","file":"/home/you/codemender-lab/sample_app/data_service.py","attempt":null}

Each line also carries actor (your git email) and commit (short SHA at the time), which is what makes a later "who allowed this and when" question answerable without a database.

Git history

git log --oneline -5
a1b2c3d docs(context): record auto-evolved convention for 80bdddd6-...
e4f5a6b fix: validate ip and use subprocess list form in /ping (CWE-78)
c7d8e9f feat: add POST /update_profile with validated input and parameterized update
...

The evolved context

tail -n 4 CONTEXT.md
## 4. Continuous Evolution: Auto-Evolved Conventions
- **Auto-Evolved Convention (80bdddd6-...)**: In `/home/you/codemender-lab/sample_app/data_service.py`, maintain defensive validation for: CRITICAL.

The hook's entry is terse because it only knows the finding ID, file, and severity. The skill-evolution-updater skill exists to turn this into a rule a human would write. Ask the agent: "Use skill-evolution-updater to rewrite the last auto-evolved convention in CONTEXT.md as a project rule, and suggest any SKILL.md change it implies."

CodeMender's view

cm report

The command injection finding should now show a non-OPEN status, and threat_model.md plus the two new test files are in the repository for the next feature to build on.

8. Cleaning Up

Restore CodeMender's interactive defaults:

sed -i.bak 's/^  confirm_commands: false/  confirm_commands: true/; s/^  confirm_writes: false/  confirm_writes: true/' ~/.codemender/config.yaml
rm ~/.codemender/config.yaml.bak

Clear CodeMender's local state (type y at the prompt):

cm clean

To remove the lab directories as well:

cd ~ && rm -rf ~/codemender-lab ~/codemender-remote.git

Congratulations! You have completed the End-to-End Agentic Security Workflow with CodeMender codelab. You have seen the CLI from the first codelab, the skills and hook from the second, and an agent working through a push that was blocked, fixed, logged, and allowed without a CodeMender prompt in the loop.