From c61aa3c3e04ad60fc2fd33b7151c078b6942d7a5 Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 18:09:37 +0330 Subject: [PATCH 1/4] feat(safety): step recorders, told about every step the executor runs Phase 10, step 5 plan, PR 7. The security layer's evidence log must hold every step of an engagement. A layer registers a recorder with add_step_recorder(); the executor tells it about each step before it runs ("start"), after it ends ("done", with its result, also when the tool crashed or returned a malformed result) and when the safety engine refuses it ("refused"). A step whose start cannot be recorded does not run, so nothing runs unrecorded; a failure to record the end is added to the step's error output. * src/codebud/safety.py (_STEP_RECORDERS, add_step_recorder) (remove_step_recorder, record_step): New. * src/codebud/executor.py (Executor.execute_plan): Record each step. * tests/test_step_checks.py: Test them. * docs/chapters/safety-and-sandbox.texi (The Safety Engine): Document them. --- docs/chapters/safety-and-sandbox.texi | 6 ++- src/codebud/executor.py | 15 ++++++ src/codebud/safety.py | 29 ++++++++++ tests/test_step_checks.py | 77 +++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/docs/chapters/safety-and-sandbox.texi b/docs/chapters/safety-and-sandbox.texi index 77a21c1..51412c5 100644 --- a/docs/chapters/safety-and-sandbox.texi +++ b/docs/chapters/safety-and-sandbox.texi @@ -230,7 +230,11 @@ is registered this way, so the core never imports that layer. A check can say what it @emph{provides} (@code{add_step_check(check, provides="engagement")}), and a profile with @code{engagement = true} refuses command and web search steps while no registered check provides -an engagement (@pxref{Profiles}). +an engagement (@pxref{Profiles}). A layer can also register a step +@emph{recorder} (@code{add_step_recorder()}): the executor tells it about +every step before it runs, after it ends and when it is refused, and a +step whose start cannot be recorded does not run --- the security +layer's evidence log is kept this way. @code{describe_rules()} returns a compact summary of the three policy lists, which is embedded in the planner prompt so the model can avoid diff --git a/src/codebud/executor.py b/src/codebud/executor.py index 6335608..b894266 100644 --- a/src/codebud/executor.py +++ b/src/codebud/executor.py @@ -22,6 +22,8 @@ from typing import Any +from codebud import safety as _safety + class Executor: """ @@ -80,14 +82,22 @@ def execute_plan(self, plan_output: dict[str, Any]) -> dict[str, Any]: if self.safety is not None: allowed, reason = self.safety.may_execute(step) if not allowed: + _safety.record_step("refused", step, {"error": reason}) return {"status": "step_refused", "error": reason, "step": step} + # A step that cannot be recorded does not run (step recorders, + # safety.py: the evidence log of an engagement). + unrecorded = _safety.record_step("start", step) + if unrecorded: + return {"status": "step_refused", "error": unrecorded, "step": step} + tool = self.tool_registry.get(tool_name) # Execute the tool try: tool_result = tool.run(args) except Exception as e: + _safety.record_step("done", step, {"error": f"crashed: {e}"}) return { "status": "step_error", "error": f"Tool '{tool_name}' crashed: {e}", @@ -96,6 +106,7 @@ def execute_plan(self, plan_output: dict[str, Any]) -> dict[str, Any]: # Validate tool result structure if not self._validate_tool_result(tool_result): + _safety.record_step("done", step, {"error": "malformed result"}) return { "status": "step_error", "error": f"Tool '{tool_name}' returned malformed result", @@ -112,6 +123,10 @@ def execute_plan(self, plan_output: dict[str, Any]) -> dict[str, Any]: # Add memory snapshot self.memory.add_snapshot(step, results[step_id]) + # Recorded after it ran; a failure here is reported with the step. + late = _safety.record_step("done", step, results[step_id]) + if late: + results[step_id]["stderr"] += f"\ncodebud: {late}" # Stop on error if tool_result.get("returncode", 0) != 0: diff --git a/src/codebud/safety.py b/src/codebud/safety.py index 811ee7c..07bb0d6 100644 --- a/src/codebud/safety.py +++ b/src/codebud/safety.py @@ -500,6 +500,35 @@ def provided(what: str) -> bool: return any(_PROVIDES.get(check) == what for check in _STEP_CHECKS) +# Step recorders (the security layer's evidence log): told about every +# step the executor runs or refuses. ``recorder(event, step, result)``, +# event "start" (before the step runs: a recorder that fails then keeps +# the step from running, so nothing runs unrecorded), "done" (with its +# result) or "refused" (result: {"error": why}). +_STEP_RECORDERS: list = [] + + +def add_step_recorder(recorder) -> None: + _STEP_RECORDERS.append(recorder) + + +def remove_step_recorder(recorder) -> None: + if recorder in _STEP_RECORDERS: + _STEP_RECORDERS.remove(recorder) + + +def record_step( + event: str, step: dict[str, Any], result: dict[str, Any] | None = None +) -> str | None: + """Tell every recorder; the first failure, as a reason, or None.""" + for recorder in list(_STEP_RECORDERS): + try: + recorder(event, step, result) + except Exception as exc: # noqa: BLE001 - a failing record is reported + return f"a step recorder failed ({type(exc).__name__}: {exc})" + return None + + def _engagement_refusal(step: dict[str, Any]) -> str | None: """A profile with ``engagement = true`` runs no command or web search unless an engagement's scope check is registered: its tools reach diff --git a/tests/test_step_checks.py b/tests/test_step_checks.py index 7813300..65c6573 100644 --- a/tests/test_step_checks.py +++ b/tests/test_step_checks.py @@ -121,3 +121,80 @@ def test_the_profile_key_is_read(tmp_path): (tmp_path / "q.toml").write_text('engagement = "yes"\n') with pytest.raises(profiles.ProfileError): profiles._load_file("q", tmp_path / "q.toml") + + +# --- step recorders (step 5 plan, PR 7: the evidence log) ----------------------------- + + +def _executor(tool=None): + from codebud.executor import Executor + + class Registry: + def get(self, name): + return tool + + class Memory: + def add_snapshot(self, step, result): + pass + + return Executor(Registry(), None, Memory(), safety=_engine()) + + +class _Tool: + def __init__(self, returncode=0): + self.ran = 0 + self.returncode = returncode + + def run(self, args): + self.ran += 1 + return {"stdout": "out", "stderr": "", "returncode": self.returncode} + + +def _plan(cmd): + return {"plan": [{"id": "step_0", "tool": "command", "description": "d", "args": {"cmd": cmd}}]} + + +@pytest.fixture +def recorded(monkeypatch): + events = [] + monkeypatch.setattr(safety, "_STEP_RECORDERS", []) + safety.add_step_recorder( + lambda event, step, result: events.append((event, step["args"]["cmd"], result)) + ) + return events + + +@pytest.mark.parametrize("returncode", [0, 1]) +def test_a_step_is_recorded_before_and_after_it_runs(recorded, returncode): + tool = _Tool(returncode) + _executor(tool).execute_plan(_plan("ls")) + assert [e[0] for e in recorded] == ["start", "done"] + assert recorded[1][2]["returncode"] == returncode and recorded[1][2]["stdout"] == "out" + + +def test_a_refused_step_is_recorded(recorded): + _executor(_Tool()).execute_plan(_plan("rm -rf /")) + assert [e[0] for e in recorded] == ["refused"] + assert recorded[0][2]["error"] + + +def test_a_step_that_cannot_be_recorded_does_not_run(monkeypatch): + monkeypatch.setattr(safety, "_STEP_RECORDERS", []) + + def broken(event, step, result): + raise OSError("disk full") + + safety.add_step_recorder(broken) + tool = _Tool() + result = _executor(tool).execute_plan(_plan("ls")) + assert tool.ran == 0 + assert result["status"] == "step_refused" and "disk full" in result["error"] + + +def test_a_crashing_tool_is_recorded(recorded): + class Crash: + def run(self, args): + raise RuntimeError("boom") + + _executor(Crash()).execute_plan(_plan("ls")) + assert [e[0] for e in recorded] == ["start", "done"] and "boom" in recorded[1][2]["error"] From 02c4a42861f17a57f407a2121fe1cd65058b3d13 Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 18:20:57 +0330 Subject: [PATCH 2/4] feat(sec): the codebud-sec command Phase 10, step 5 plan, PR 7 (D14a). codebud-sec chat|run|plan --engagement FILE runs codebud inside an engagement: it loads the engagement, opens its evidence log (created encrypted beside the file, .evidence.jsonl, or the one --evidence names; reopened with its chain checked for later sessions), activates the scope guard and network fence, registers the evidence recorder, and hands over to codebud with a security profile -- security-pentest unless --profile names another; other options pass through. When codebud ends, everything is taken down and the session's end and exit status are recorded. Every step goes into the log before it runs and after it ends, refused steps as warnings; text over 64 KiB is kept as its SHA-256, size and head. codebud-sec evidence add records files with their SHA-256 and a note; verify checks the chain (--files: the recorded files too) and prints the head hash to keep elsewhere; show lists the entries; close ends the log. codebud-sec pin is codebud integrity pin. Verified live with Ollama: a planned nmap step without --unprivileged was refused at plan time; with it, codebud-sec run ran it behind the fence, and the log held both records and verified. The layer now calls codebud's own main: CORE_API += codebud.cli.main. make install and pip install both provide the command. * src/codebud_sec/cli.py, src/codebud_sec/__main__.py: New files. * pyproject.toml (project.scripts): Add codebud-sec. * Makefile.am (bin_SCRIPTS): Build codebud-sec from the launcher. * src/launcher.in: Name the module and command. * src/Makefile.am: Install the new modules. * .gitignore: Ignore the built launcher. * tests/test_layers.py (CORE_API): Add codebud.cli.main. * tests/test_codebud_sec.py: New file. * docs/chapters/security.texi: New chapter, "Security Work". * docs/codebud.texi, docs/Makefile.am: Include it. * docs/chapters/architecture.texi, docs/chapters/development.texi: Mention them. * README.md, NEWS: Describe codebud-sec. --- .gitignore | 1 + Makefile.am | 10 +- NEWS | 14 +- README.md | 23 ++- docs/Makefile.am | 1 + docs/chapters/architecture.texi | 2 + docs/chapters/development.texi | 2 + docs/chapters/security.texi | 178 ++++++++++++++++++++++ docs/codebud.texi | 2 + pyproject.toml | 1 + src/Makefile.am | 2 + src/codebud_sec/__main__.py | 11 ++ src/codebud_sec/cli.py | 253 ++++++++++++++++++++++++++++++++ src/launcher.in | 6 +- tests/test_codebud_sec.py | 205 ++++++++++++++++++++++++++ tests/test_layers.py | 1 + 16 files changed, 702 insertions(+), 10 deletions(-) create mode 100644 docs/chapters/security.texi create mode 100644 src/codebud_sec/__main__.py create mode 100644 src/codebud_sec/cli.py create mode 100644 tests/test_codebud_sec.py diff --git a/.gitignore b/.gitignore index d5441c0..20132fd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ Makefile.in /config.log /config.status /codebud +/codebud-sec /codebud-*.tar.gz /codebud-*.tar.gz.sig /codebud-*.tar.gz.sha256 diff --git a/Makefile.am b/Makefile.am index e3cb322..c3c60e8 100644 --- a/Makefile.am +++ b/Makefile.am @@ -11,13 +11,17 @@ SUBDIRS = src docs # ─── The codebud command ───────────────────────────────────────────────────── # A small POSIX sh launcher for the configured Python and package directory. -bin_SCRIPTS = codebud -CLEANFILES = codebud +bin_SCRIPTS = codebud codebud-sec +CLEANFILES = codebud codebud-sec -codebud: $(srcdir)/src/launcher.in Makefile +# codebud runs the codebud package, codebud-sec the security layer. +codebud codebud-sec: $(srcdir)/src/launcher.in Makefile $(AM_V_GEN)rm -f $@ $@.tmp && \ + module=`echo $@ | sed 's/-/_/'` && \ $(SED) -e 's|@PYTHON[@]|$(PYTHON)|g' \ -e 's|@pythondir[@]|$(pythondir)|g' \ + -e "s|@MODULE[@]|$$module|g" \ + -e 's|@COMMAND[@]|$@|g' \ $(srcdir)/src/launcher.in > $@.tmp && \ chmod +x $@.tmp && mv $@.tmp $@ diff --git a/NEWS b/NEWS index c35c201..3f0d081 100644 --- a/NEWS +++ b/NEWS @@ -85,6 +85,16 @@ a safety policy you can read and change. exploitation tools, no files sent to a target. A profile requires an engagement with engagement = true. + codebud-sec, the security layer's command: codebud-sec chat|run|plan + --engagement FILE runs codebud inside an engagement -- its scope guard + and network fence active, every step recorded before it runs and after + it ends in an encrypted, hash-chained evidence log beside the + engagement file. codebud-sec evidence add|verify|show|close keeps the + log; codebud-sec pin is codebud integrity pin. Scanning steps run + behind a network fence (pasta and nftables, optional): only their + targets are reachable, no DNS, nothing of the machine itself. A new + manual chapter, "Security Work". + A profile can ask for observer isolation (isolation = "observer"): command steps see the host's processes through a read-only /proc, for live triage, but still cannot signal them or reach the network. @@ -107,5 +117,5 @@ a safety policy you can read and change. Alpha: the command line, configuration and data formats may still change before 1.0. Isolation needs bubblewrap and user namespaces (GNU/Linux); elsewhere steps run unisolated with a warning. The - security layer (codebud_sec: engagement file, evidence log) ships but - is not used yet. + security layer (codebud_sec) is for authorized work only and is used + through codebud-sec; its network fence needs pasta (passt) and nft. diff --git a/README.md b/README.md index d613cc3..0d35b54 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,24 @@ codebud integrity pin # record what is installed n codebud integrity verify # what changed since (exit 1 if anything) ``` +## Security work + +For authorized security work only -- incident response on hosts you own, +penetration tests you are contracted to run -- `codebud-sec` runs codebud +inside an engagement: a TOML file with the client, the in-scope and +excluded targets and the time window. Every network target a step names is +checked against it (out of scope is refused), scanning steps run where the +network itself lets through only their targets, and every step goes into +an encrypted, hash-chained evidence log. + +```bash +codebud-sec chat --engagement acme.toml # security-pentest profile +codebud-sec evidence add disk.E01 --engagement acme.toml +codebud-sec evidence verify --engagement acme.toml # record the head hash elsewhere +``` + +See "Security Work" in the manual. + ## Project search Index a project once and every request gets its most relevant code as @@ -151,14 +169,15 @@ make pdf # → docs/codebud.pdf Topics covered: Introduction · Installation · Model Management · LLM Execution · Performance Benchmarking · Usage · Configuration · -Architecture · Tools Reference · Safety & Sandbox · Development · +Architecture · Tools Reference · Safety & Sandbox · Privacy · Security Work · +Development · Troubleshooting · Glossary. ## Project layout ``` src/codebud/ The package: agent, CLI (cli/), tools, default policy -src/codebud_sec/ Security layer (engagement file, evidence log); not used yet +src/codebud_sec/ Security layer: codebud-sec, engagements, scope, evidence log docs/ GNU Texinfo manual scripts/ Developer scripts (benchmark, search and plan evals) tests/ Test suite (pytest) diff --git a/docs/Makefile.am b/docs/Makefile.am index 5105379..9607412 100644 --- a/docs/Makefile.am +++ b/docs/Makefile.am @@ -17,6 +17,7 @@ codebud_TEXINFOS = \ chapters/model-management.texi \ chapters/performance-benchmarking.texi \ chapters/privacy.texi \ + chapters/security.texi \ chapters/safety-and-sandbox.texi \ chapters/tools-reference.texi \ chapters/troubleshooting.texi \ diff --git a/docs/chapters/architecture.texi b/docs/chapters/architecture.texi index 5684108..7c246bd 100644 --- a/docs/chapters/architecture.texi +++ b/docs/chapters/architecture.texi @@ -637,6 +637,8 @@ src/codebud/ the package └── host_network.py host_network (observer profiles only) src/codebud_sec/ the security layer (authorized security work) +├── cli.py the codebud-sec command: sessions inside an +│ engagement, evidence add/verify/show/close, pin ├── engagement.py engagement file: authorized scope ├── scope.py scope guard: every network target read from the │ command line and checked (DNS tools, whois, diff --git a/docs/chapters/development.texi b/docs/chapters/development.texi index cc11514..add5147 100644 --- a/docs/chapters/development.texi +++ b/docs/chapters/development.texi @@ -55,6 +55,8 @@ tests/ ├── test_chat_input.py / menu, toolbar, Shift-Tab (prompt_toolkit) ├── test_cli.py subcommands, doctor, display, chat REPL, │ history, sessions, modes, run --yes +├── test_codebud_sec.py codebud-sec: guard, fence and recorder on +│ for a session only; evidence commands; pin ├── test_confine.py file tools: project-only writes, no .git/ │ .codebud/, no secret reads, symlinks, display ├── test_core.py AgentCore: models, /compact, context usage diff --git a/docs/chapters/security.texi b/docs/chapters/security.texi new file mode 100644 index 0000000..ac0ddec --- /dev/null +++ b/docs/chapters/security.texi @@ -0,0 +1,178 @@ +@node Security Work +@chapter Security Work +@cindex security work +@cindex codebud-sec + +Codebud's security layer (@file{codebud_sec}, part of the Codebud +distribution) is for @emph{authorized} security work: incident response +on hosts you own or are authorized to examine, and penetration tests you +are contracted to run. It adds an engagement file that states what was +authorized, a command, @command{codebud-sec}, that keeps every step to +it, and an evidence log that records every step. Nothing here grants +access to anything; it only states, and helps keep to, what was already +authorized. Use it only within a written authorization (rules of +engagement, a signed scope, an incident ticket). + +@menu +* Security Work at a Glance:: What codebud-sec adds to codebud. +* The Engagement File:: Client, scope, window, fence. +* Running codebud-sec:: Sessions, evidence, pinning. +* The Evidence Log:: What is recorded and how to check it. +* What Security Work Does Not Cover:: The limits, plainly. +@end menu + + +@node Security Work at a Glance +@section Security Work at a Glance + +@multitable @columnfractions .32 .68 +@headitem Piece @tab What it does +@item Security profiles +@tab @code{security-ir} (incident response: read-only triage and +forensics, no network) and @code{security-pentest} (reconnaissance and +scanning of in-scope hosts); @pxref{Profiles}. +@item Engagement file +@tab A TOML file naming the client, the in-scope and excluded targets and +the time window. +@item Scope guard +@tab Reads every host a network step will contact from its command line +and refuses the step when one is out of scope, excluded, or the window +is closed (lookups out of scope ask instead). A command it cannot read +is refused. +@item Network fence +@tab Runs scanning steps where the network itself lets through only +their targets (@pxref{Isolated Steps}). +@item Evidence log +@tab An encrypted, hash-chained, append-only record of every step, before +it runs and after it ends, refused steps too. +@item Integrity +@tab Both profiles refuse to start without a pinned manifest of the +models and programs they run (@pxref{Integrity Checks}). +@end multitable + +The security profiles need an encryption key (@command{codebud key +init}) and a pinned manifest (@command{codebud-sec pin}). +@code{security-pentest} runs no command until an engagement is open, so +it is used through @command{codebud-sec}. + + +@node The Engagement File +@section The Engagement File +@cindex engagement file + +@example +client = "ACME Ltd" +ticket = "PT-2026-042" +operator = "A. Analyst" +in_scope = ["10.20.0.0/16", "app.acme.example", "2001:db8:42::/48"] +out_of_scope = ["10.20.5.0/24", "admin.app.acme.example"] +starts = 2026-11-16T08:00:00Z +ends = 2026-11-20T18:00:00Z +fence_scanning = true +notes = "Production: no denial of service; scans 08:00-18:00 only." +@end example + +@table @code +@item in_scope +Networks (CIDR), addresses and names. A name covers its subdomains +(@code{app.acme.example} covers @code{api.app.acme.example}). Required. +@item out_of_scope +Excluded networks, addresses and names; they win over @code{in_scope}. +A network is in scope only when all of it is. +@item starts, ends +The window the work is authorized for (UTC unless an offset is given). +Outside it, active steps are refused. +@item fence_scanning +@code{true} (the default) runs scanning steps behind the network fence; +@code{false} gives raw scans (nmap SYN, UDP and OS scans, masscan) back, +with the scope guard as their only check. +@item client, ticket, operator, notes +Recorded in the evidence log. +@end table + +A name is never matched against an address, and the guard never +resolves a name to decide scope; the fence resolves the names a step +uses just before it runs. + + +@node Running codebud-sec +@section Running codebud-sec + +@example +codebud-sec chat --engagement acme.toml +codebud-sec run --engagement acme.toml "scan the web tier for open ports" +codebud-sec plan --engagement acme.toml --profile security-ir "..." +@end example + +@noindent +@command{chat}, @command{run} and @command{plan} open the engagement and +then run codebud itself, with the @code{security-pentest} profile unless +@option{--profile} names another. Any other option is passed on to +codebud (@option{--resume}, @option{-m}, @dots{}). For the whole session +the scope guard and the network fence are active and every step is +recorded; when it ends they are taken down. Outside the window, +@command{codebud-sec} says so at the start and active steps are refused. + +@example +codebud-sec evidence add disk.E01 rules-of-engagement.pdf \ + -m "image received from the client" --engagement acme.toml +codebud-sec evidence verify [--files] --engagement acme.toml +codebud-sec evidence show --engagement acme.toml +codebud-sec evidence close --engagement acme.toml +codebud-sec pin +@end example + +@noindent +@command{evidence add} records files with their SHA-256 (and a note). +@command{verify} checks the hash chain, and with @option{--files} that +the recorded files are unchanged. @command{show} lists the entries. +@command{close} ends the log: nothing can be added after it. +@command{pin} is @command{codebud integrity pin}. @option{--evidence +LOG} names a log other than the default, @file{.evidence.jsonl} +beside the engagement file. + + +@node The Evidence Log +@section The Evidence Log +@cindex evidence log + +The log is a JSON Lines file, one entry per line, each holding the hash +of the one before; editing, removing or reordering an entry breaks the +chain. The first entry names the engagement file with its SHA-256, so +the log says exactly what was authorized. Each session adds a +@code{start} and an @code{end} note; each step a @code{step} entry before +it runs (the tool, its arguments, the description) and one after it ends +(its exit status and output); a refused step a @code{warning} with the +reason. A step whose start cannot be recorded does not run. Output or +arguments longer than 64 KiB are kept as their SHA-256, size and +beginning. + +The log is encrypted at rest with your key: each entry is sealed, and +only its sequence number, kind and the two hashes stay readable, so that +appending needs no passphrase. Reading and a full @command{verify} ask +for it. + +@cindex head hash +What the chain proves is relative to its head: anyone who can write the +file could rewrite it consistently. @command{verify} prints the head +hash --- record it outside the log (the case notes, a ticket, a signed +message), and the log then shows any change made before that point. + + +@node What Security Work Does Not Cover +@section What Security Work Does Not Cover + +@itemize +@item Authorization. The engagement file states it; it does not grant +it, and Codebud cannot tell whether it is true. +@item Names. The client's DNS decides what an in-scope name resolves to, +and an address shared with other sites (a CDN) is reachable while a step +that names it runs. +@item Lookups. @command{dig}, @command{host} and @command{whois} are not +fenced; out of scope they ask rather than refuse. +@item Raw scans. With @code{fence_scanning = false}, scanning relies on +the scope guard reading the command line. +@item The machine itself. The evidence log and the integrity checks +detect changes made through Codebud and to the files they cover; a +compromised operating system can defeat both (RFC 3227, section 5). +@end itemize diff --git a/docs/codebud.texi b/docs/codebud.texi index 9df6dd7..5716e3b 100644 --- a/docs/codebud.texi +++ b/docs/codebud.texi @@ -57,6 +57,7 @@ This manual is divided into the following chapters: * Tools Reference:: Every built-in tool documented. * Safety and Sandbox:: How Codebud limits what the LLM can do. * Privacy:: What stays on your machine, and how to check. +* Security Work:: Engagements, codebud-sec and the evidence log. * Development:: Adding tools, running tests, iterating on prompts. * Troubleshooting:: Common failures and how to fix them. * Glossary:: Key terms defined. @@ -74,6 +75,7 @@ This manual is divided into the following chapters: @include chapters/tools-reference.texi @include chapters/safety-and-sandbox.texi @include chapters/privacy.texi +@include chapters/security.texi @include chapters/development.texi @include chapters/troubleshooting.texi @include chapters/glossary.texi diff --git a/pyproject.toml b/pyproject.toml index 656b57f..e79f630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dev = [ [project.scripts] codebud = "codebud.cli.main:main" +codebud-sec = "codebud_sec.cli:main" [project.urls] Homepage = "https://github.com/mkhellat/codebud" diff --git a/src/Makefile.am b/src/Makefile.am index f042f93..177dc34 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -61,6 +61,8 @@ nobase_python_PYTHON = \ codebud/tools/tool_registry.py \ codebud/tools/web_search.py \ codebud_sec/__init__.py \ + codebud_sec/__main__.py \ + codebud_sec/cli.py \ codebud_sec/engagement.py \ codebud_sec/evidence.py \ codebud_sec/fence.py \ diff --git a/src/codebud_sec/__main__.py b/src/codebud_sec/__main__.py new file mode 100644 index 0000000..98faa76 --- /dev/null +++ b/src/codebud_sec/__main__.py @@ -0,0 +1,11 @@ +""" +codebud_sec/__main__.py + +Lets `python -m codebud_sec ...` run the codebud-sec command. +""" + +import sys + +from codebud_sec.cli import main + +sys.exit(main()) diff --git a/src/codebud_sec/cli.py b/src/codebud_sec/cli.py new file mode 100644 index 0000000..da42da0 --- /dev/null +++ b/src/codebud_sec/cli.py @@ -0,0 +1,253 @@ +""" +codebud_sec/cli.py + +The codebud-sec command (phase 10, step 5 plan, PR 7; D14a): codebud +for authorized security work, with an engagement open. + + codebud-sec chat|run|plan --engagement FILE [--evidence LOG] [--profile NAME] ... + codebud-sec evidence add FILE... --engagement FILE [-m NOTE] + codebud-sec evidence verify|show|close --engagement FILE + codebud-sec pin [...] (codebud integrity pin) + +chat, run and plan open the engagement and then hand over to codebud +itself (codebud.cli.main) with a security profile -- security-pentest +unless --profile names another. While it runs: + +- the engagement's scope guard and network fence are active + (scope.ScopeGuard): every network target is judged, scanning steps + are fenced to their targets, and a profile with engagement = true + runs commands only now; +- every step is recorded in the engagement's evidence log before it runs + and after it ends, refused steps too (codebud.safety step recorders); + a step that cannot be recorded does not run. The log is created + encrypted next to the engagement file (.evidence.jsonl) unless + --evidence names another, and reopened, its chain checked, for later + sessions. +""" + +import argparse +import hashlib +import sys +from pathlib import Path + +from codebud import crypto + +from . import evidence +from .engagement import EngagementError, load +from .scope import ScopeGuard + +PROG = "codebud-sec" +DEFAULT_PROFILE = "security-pentest" +SESSION_COMMANDS = ("chat", "run", "plan") +# Longer text in a step or its output is kept as its SHA-256, size and head. +_TEXT_LIMIT = 64 * 1024 + + +def _fit(value): + """``value`` for the log: long text as its hash, size and beginning.""" + if isinstance(value, str) and len(value) > _TEXT_LIMIT: + raw = value.encode("utf-8", "replace") + return { + "sha256": hashlib.sha256(raw).hexdigest(), + "bytes": len(raw), + "head": value[: _TEXT_LIMIT // 4], + } + if isinstance(value, dict): + return {k: _fit(v) for k, v in value.items()} + if isinstance(value, list): + return [_fit(v) for v in value] + return value + + +class EvidenceRecorder: + """A codebud.safety step recorder writing to an evidence log.""" + + def __init__(self, log: evidence.EvidenceLog): + self.log = log + + def __call__(self, event: str, step: dict, result: dict | None) -> None: + data = { + "event": event, + "step": _fit({k: step.get(k) for k in ("id", "tool", "description", "args")}), + } + if result is not None: + data["result"] = _fit({k: v for k, v in result.items() if k != "metadata"}) + self.log.append("warning" if event == "refused" else "step", data) + + +def evidence_path(engagement_file: Path, given: str | None) -> Path: + if given: + return Path(given) + return engagement_file.with_name(engagement_file.stem + ".evidence.jsonl") + + +def _open_log(engagement, path: Path) -> evidence.EvidenceLog: + if path.exists(): + return evidence.EvidenceLog.open(path) + return evidence.EvidenceLog.create(path, engagement=engagement, encrypt=True) + + +def _fail(message: str) -> int: + print(f"{PROG}: {message}", file=sys.stderr) + return 2 + + +def _session(args, rest: list[str]) -> int: + from codebud.cli.main import main as run_codebud + + try: + engagement = load(args.engagement) + except EngagementError as exc: + return _fail(str(exc)) + if not crypto.has_key(): + return _fail("the evidence log is encrypted: create a key first (codebud key init)") + path = evidence_path(Path(args.engagement), args.evidence) + try: + log = _open_log(engagement, path) + except evidence.EvidenceError as exc: + return _fail(str(exc)) + if not engagement.in_window(): + print( + f"{PROG}: the engagement window is not open now; active steps will be refused", + file=sys.stderr, + ) + print(f"{PROG}: engagement {engagement.header()}; evidence log {path}", file=sys.stderr) + + from codebud import safety + + guard, recorder = ScopeGuard(engagement), EvidenceRecorder(log) + log.append("note", {"session": "start", "command": args.command, "profile": args.profile}) + guard.activate() + safety.add_step_recorder(recorder) + argv = ["codebud", "--profile", args.profile, args.command, *rest] + saved, sys.argv = sys.argv, argv + status = 0 + try: + run_codebud() + except SystemExit as exc: + status = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1) + finally: + sys.argv = saved + safety.remove_step_recorder(recorder) + guard.deactivate() + log.append("note", {"session": "end", "status": status}) + return status + + +def _log_for(args) -> tuple[Path | None, object]: + try: + engagement = load(args.engagement) + except EngagementError as exc: + return None, str(exc) + return evidence_path(Path(args.engagement), args.evidence), engagement + + +def _unlock() -> str | None: + try: + crypto.ensure_unlocked() + except crypto.CryptoError as exc: + return str(exc) + return None + + +def _evidence(args) -> int: + path, engagement = _log_for(args) + if path is None: + return _fail(engagement) + if args.action == "add": + missing = [f for f in args.files if not Path(f).is_file()] + if missing: + return _fail(f"not a file: {', '.join(missing)}") + try: + log = _open_log(engagement, path) + entry = log.append( + "note", {"note": args.message or "", "added": args.files}, args.files + ) + except evidence.EvidenceError as exc: + return _fail(str(exc)) + for record in entry.get("artefacts", []): + print(f"{record.get('sha256', '?')} {record.get('path', '?')}") + return 0 + if not path.exists(): + return _fail(f"no evidence log at {path}") + if args.action == "close": + try: + evidence.EvidenceLog.open(path).close({"by": PROG}) + except evidence.EvidenceError as exc: + return _fail(str(exc)) + print(f"{PROG}: closed {path}") + return 0 + problem = _unlock() + if problem: + return _fail(problem) + if args.action == "verify": + result = evidence.verify(path, check_artefacts=args.files_too) + if result.artefact_problems: + return _fail("recorded files changed: " + "; ".join(result.artefact_problems)) + if not result.ok: + return _fail(f"{path} does not verify: {result.error}") + print(f"{path}: {result.entries} entries, chain intact; head {result.head}") + print("Record the head hash outside the log: it shows any later change.") + return 0 + for entry in evidence.entries(path): + data = entry.get("data", {}) + what = data.get("event") or data.get("session") or data.get("note") or "" + step = data.get("step", {}).get("args", {}) if isinstance(data.get("step"), dict) else {} + detail = step.get("cmd") or step.get("path") or "" + print( + f"{entry.get('seq'):>5} {entry.get('time', '')} {entry.get('kind'):8} {what} {detail}" + ) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=PROG, description="codebud for authorized security work, with an engagement open." + ) + sub = parser.add_subparsers(dest="command", required=True) + + def engagement_options(p): + p.add_argument("--engagement", required=True, metavar="FILE", help="the engagement file") + p.add_argument("--evidence", metavar="LOG", help="the evidence log (default: beside it)") + + for name in SESSION_COMMANDS: + p = sub.add_parser(name, help=f"codebud {name} inside the engagement", add_help=False) + engagement_options(p) + p.add_argument("--profile", default=DEFAULT_PROFILE, help="a security profile") + ev = sub.add_parser("evidence", help="the engagement's evidence log") + ev_sub = ev.add_subparsers(dest="action", required=True) + add = ev_sub.add_parser("add", help="record files (their SHA-256) in the log") + add.add_argument("files", nargs="+") + add.add_argument("-m", "--message", help="a note to go with them") + engagement_options(add) + for action, text in ( + ("verify", "check the hash chain"), + ("show", "list the entries"), + ("close", "end the log"), + ): + p = ev_sub.add_parser(action, help=text) + engagement_options(p) + if action == "verify": + p.add_argument("--files", dest="files_too", action="store_true", + help="also check the recorded files' hashes") # fmt: skip + sub.add_parser("pin", help="codebud integrity pin", add_help=False) + return parser + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if argv[:1] == ["pin"]: + from codebud.cli.main import main as run_codebud + + saved, sys.argv = sys.argv, ["codebud", "integrity", "pin", *argv[1:]] + try: + run_codebud() + finally: + sys.argv = saved + return 0 + args, rest = build_parser().parse_known_args(argv) + if args.command in SESSION_COMMANDS: + return _session(args, rest) + if rest: + return _fail(f"unknown arguments: {' '.join(rest)}") + return _evidence(args) diff --git a/src/launcher.in b/src/launcher.in index 29e3a88..1f271ad 100644 --- a/src/launcher.in +++ b/src/launcher.in @@ -1,10 +1,10 @@ #!/bin/sh -# codebud — command installed by `make install` (GNU Autotools build). +# @COMMAND@ — command installed by `make install` (GNU Autotools build). # -# Runs the codebud package with the Python it was configured for. The +# Runs the @MODULE@ package with the Python it was configured for. The # package directory is put on PYTHONPATH because a --prefix install (e.g. # /usr/local) is not on every system Python's default module path. PYTHONPATH="@pythondir@${PYTHONPATH:+:$PYTHONPATH}" export PYTHONPATH -exec "@PYTHON@" -m codebud "$@" +exec "@PYTHON@" -m @MODULE@ "$@" diff --git a/tests/test_codebud_sec.py b/tests/test_codebud_sec.py new file mode 100644 index 0000000..c19d41d --- /dev/null +++ b/tests/test_codebud_sec.py @@ -0,0 +1,205 @@ +""" +tests/test_codebud_sec.py + +The codebud-sec command (phase 10, step 5 plan, PR 7): a session opens +the engagement -- scope guard and network fence active, every step +recorded in its encrypted evidence log -- and hands over to codebud with +a security profile; evidence add/verify/show/close keep the log; pin is +codebud integrity pin. +""" + +import pytest + +from codebud import isolation, safety +from codebud_sec import cli, evidence + +SCOPE = """\ +client = "ACME" +ticket = "PT-42" +in_scope = ["10.0.0.0/16"] +""" + + +@pytest.fixture +def case(tmp_path, monkeypatch, fast_passphrase): + from codebud import crypto + + crypto.create_key("pw") + crypto.lock() + monkeypatch.setenv("CODEBUD_PASSPHRASE", "pw") + monkeypatch.setattr(safety, "_STEP_CHECKS", []) + monkeypatch.setattr(safety, "_STEP_NOTICES", []) + monkeypatch.setattr(safety, "_STEP_RECORDERS", []) + monkeypatch.setattr(isolation, "_FENCES", []) + monkeypatch.chdir(tmp_path) + (tmp_path / "acme.toml").write_text(SCOPE) + return tmp_path + + +@pytest.fixture +def core(monkeypatch): + """codebud's own main, replaced: it records what it was started with, + what was active, and runs one step through the recorders.""" + seen = {} + + def main(): + import sys + + seen["argv"] = list(sys.argv) + seen["engagement"] = safety.provided("engagement") + seen["fenced"] = bool(isolation._FENCES) + step = { + "id": "step_0", + "tool": "command", + "description": "scan", + "args": {"cmd": "nmap 10.0.0.5"}, + } + assert safety.record_step("start", step) is None + safety.record_step("done", step, {"stdout": "80/tcp open", "stderr": "", "returncode": 0}) + if seen.get("exit"): + raise SystemExit(seen["exit"]) + + monkeypatch.setattr("codebud.cli.main.main", main) + return seen + + +def _entries(case): + from codebud import crypto + + crypto.ensure_unlocked() + return evidence.entries(case / "acme.evidence.jsonl") + + +def test_a_session_runs_codebud_inside_the_engagement(case, core): + status = cli.main(["chat", "--engagement", "acme.toml", "--resume"]) + assert status == 0 + assert core["argv"] == ["codebud", "--profile", "security-pentest", "chat", "--resume"] + assert core["engagement"] and core["fenced"] + # Everything is taken down afterwards. + assert not safety.provided("engagement") and isolation._FENCES == [] + assert safety._STEP_RECORDERS == [] + + +def test_every_step_is_in_the_encrypted_evidence_log(case, core): + cli.main(["run", "--engagement", "acme.toml", "scan the web tier"]) + log = case / "acme.evidence.jsonl" + assert "nmap" not in log.read_text() # sealed + entries = _entries(case) + kinds = [(e["kind"], e["data"].get("event") or e["data"].get("session")) for e in entries] + assert kinds == [ + ("open", None), + ("note", "start"), + ("step", "start"), + ("step", "done"), + ("note", "end"), + ] + assert entries[0]["data"]["engagement_sha256"] + assert entries[3]["data"]["result"]["stdout"] == "80/tcp open" + assert evidence.verify(log).ok + + +def test_later_sessions_append_to_the_same_log(case, core): + cli.main(["run", "--engagement", "acme.toml", "one"]) + cli.main(["run", "--engagement", "acme.toml", "two"]) + entries = _entries(case) + assert [e["kind"] for e in entries].count("open") == 1 + assert len(entries) == 9 + + +def test_the_profile_and_log_can_be_chosen(case, core): + cli.main( + ["chat", "--engagement", "acme.toml", "--profile", "security-ir", "--evidence", "ir.jsonl"] + ) + assert core["argv"][:3] == ["codebud", "--profile", "security-ir"] + assert (case / "ir.jsonl").exists() and not (case / "acme.evidence.jsonl").exists() + + +def test_codebuds_exit_status_is_kept_and_recorded(case, core): + core["exit"] = 3 + assert cli.main(["run", "--engagement", "acme.toml", "x"]) == 3 + assert _entries(case)[-1]["data"] == {"session": "end", "status": 3} + + +def test_long_output_is_kept_as_its_hash(case, core, monkeypatch): + long = "x" * (cli._TEXT_LIMIT + 1) + recorder = cli.EvidenceRecorder(evidence.EvidenceLog.create(case / "l.jsonl", encrypt=False)) + recorder("done", {"id": "s", "tool": "command", "args": {"cmd": "cat big"}}, {"stdout": long}) + stdout = evidence.entries(case / "l.jsonl")[-1]["data"]["result"]["stdout"] + assert stdout["bytes"] == len(long) and len(stdout["sha256"]) == 64 + + +@pytest.mark.parametrize( + ("argv", "why"), + [ + (["chat", "--engagement", "missing.toml"], "no engagement file"), + (["chat"], "no engagement given"), + ], +) +def test_a_session_needs_an_engagement(case, core, argv, why): + with pytest.raises(SystemExit) if argv == ["chat"] else _nothing(): + assert cli.main(argv) == 2, why + assert "argv" not in core + + +class _nothing: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def test_a_session_needs_a_key(case, core, monkeypatch): + from codebud import crypto + + monkeypatch.setattr(crypto, "has_key", lambda: False) + assert cli.main(["chat", "--engagement", "acme.toml"]) == 2 + assert "argv" not in core + + +def test_evidence_add_verify_show_close(case, capsys): + (case / "disk.raw").write_bytes(b"\0" * 64) + assert ( + cli.main( + [ + "evidence", + "add", + "disk.raw", + "-m", + "image of the web server", + "--engagement", + "acme.toml", + ] + ) + == 0 + ) + assert evidence.file_sha256(case / "disk.raw") in capsys.readouterr().out + assert cli.main(["evidence", "verify", "--files", "--engagement", "acme.toml"]) == 0 + assert "chain intact" in capsys.readouterr().out + assert cli.main(["evidence", "show", "--engagement", "acme.toml"]) == 0 + assert "image of the web server" in capsys.readouterr().out + assert cli.main(["evidence", "close", "--engagement", "acme.toml"]) == 0 + # Closed: nothing more is added. + assert cli.main(["evidence", "add", "disk.raw", "--engagement", "acme.toml"]) == 2 + + +def test_verify_reports_a_changed_file(case, capsys): + (case / "disk.raw").write_bytes(b"\0" * 64) + cli.main(["evidence", "add", "disk.raw", "--engagement", "acme.toml"]) + (case / "disk.raw").write_bytes(b"\1" * 64) + assert cli.main(["evidence", "verify", "--files", "--engagement", "acme.toml"]) == 2 + assert "changed" in capsys.readouterr().err + + +def test_pin_is_codebud_integrity_pin(monkeypatch): + seen = {} + + def main(): + import sys + + seen["argv"] = list(sys.argv) + + monkeypatch.setattr("codebud.cli.main.main", main) + assert cli.main(["pin", "--command", "nmap"]) == 0 + assert seen["argv"] == ["codebud", "integrity", "pin", "--command", "nmap"] + diff --git a/tests/test_layers.py b/tests/test_layers.py index 531f4d9..cf641ad 100644 --- a/tests/test_layers.py +++ b/tests/test_layers.py @@ -15,6 +15,7 @@ # The core modules codebud_sec may import. CORE_API = { + "codebud.cli.main", # codebud-sec hands its sessions over to codebud (PR 7) "codebud.crypto", # encryption at rest: the evidence log (D14b) "codebud.fsutil", "codebud.isolation", # the scope guard needs isolated steps (review of #18) From 165b098726d0c9a974ca3a9bf35c5f8ae22d3ddd Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 18:20:57 +0330 Subject: [PATCH 3/4] feat(cli): a scope refusal gets its own hint A step the engagement's scope refused was explained as a safety policy refusal, whose hint is to add the command to an allow-list -- which cannot widen an engagement's scope. It now says the step reaches outside the engagement or cannot be read, and to rephrase it so each step names only in-scope targets and meets the reason given. * src/codebud/cli/errors.py (_RULES): Add "engagement scope" before "safety policy". * tests/test_codebud_sec.py (test_a_scope_refusal_gets_its_own_hint): New test. --- src/codebud/cli/errors.py | 8 ++++++++ tests/test_codebud_sec.py | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/codebud/cli/errors.py b/src/codebud/cli/errors.py index 6b7fd3b..c3d3606 100644 --- a/src/codebud/cli/errors.py +++ b/src/codebud/cli/errors.py @@ -103,6 +103,14 @@ "The active agent mode does not allow this tool.", "Switch the mode: /mode ask in chat, or --mode ask.", ), + # Scope before policy: an engagement's refusal is reported as the policy's. + ( + "engagement scope", + "A plan step reaches outside the open engagement, or its targets cannot be " + "read; the reason says which.", + "Rephrase the request so each step names only in-scope targets and meets the " + "reason given. The scope is the engagement file's: no allow-list can widen it.", + ), ( "safety policy", "A plan step uses a command or argument the safety policy does not allow.", diff --git a/tests/test_codebud_sec.py b/tests/test_codebud_sec.py index c19d41d..d78f08d 100644 --- a/tests/test_codebud_sec.py +++ b/tests/test_codebud_sec.py @@ -203,3 +203,15 @@ def main(): assert cli.main(["pin", "--command", "nmap"]) == 0 assert seen["argv"] == ["codebud", "integrity", "pin", "--command", "nmap"] + + +def test_a_scope_refusal_gets_its_own_hint(): + """Not the allow-list advice: no allow-list widens an engagement.""" + from codebud.cli.errors import explain + + error = ( + "The safety policy refused step 1 (nmap 192.168.1.1): engagement scope: " + "nmap: 192.168.1.1 is not in the engagement scope (acme.toml)" + ) + explanation, fix = explain(error) + assert "engagement" in explanation and "harmless_commands" not in fix From a3eb006e7dd1d1d422e5e8bc8cd05815d43fc259 Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 18:25:44 +0330 Subject: [PATCH 4/4] fix(sec): keep the evidence log out of the steps' reach, tied to its engagement Review of #23. Three defects, each reproduced: The default log lay beside the engagement file, usually in the project, which steps may write (file_write accepted it; any command step could write it too): a step could truncate it or replace it consistently. It now lives in codebud's data directory, evidence/-.jsonl, which steps never see; an --evidence path inside the project is refused. The engagement file was reloaded at each session without comparing it to the SHA-256 the log's open entry records: a scope widened to 0.0.0.0/0 between sessions opened on the same log. Reopening a log now unlocks the key, checks the whole chain, and refuses an engagement file other than the one the log was opened with. A message codebud exited with (sys.exit("...")) became status 1 with no text; it is printed now, as Python would. CORE_API grows by codebud.paths (the data directory) and codebud.confine (the project root), deliberately. * src/codebud_sec/cli.py (evidence_path): The data directory by default; refuse a path inside the project. (_open_log): Unlock, check the chain and the engagement file. (_session): Show codebud's exit message. (_log_for): Report a refused path. * tests/test_layers.py (CORE_API): Add codebud.paths, codebud.confine. * tests/test_codebud_sec.py: Follow the new place; test the fixes. * docs/chapters/security.texi (Running codebud-sec), NEWS: Say where the log is kept and that a changed scope needs a new log. --- NEWS | 3 +- docs/chapters/security.texi | 13 ++++++-- src/codebud_sec/cli.py | 60 +++++++++++++++++++++++++++++-------- tests/test_codebud_sec.py | 55 +++++++++++++++++++++++++++++----- tests/test_layers.py | 2 ++ 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/NEWS b/NEWS index 3f0d081..8cfb1c0 100644 --- a/NEWS +++ b/NEWS @@ -88,7 +88,8 @@ a safety policy you can read and change. codebud-sec, the security layer's command: codebud-sec chat|run|plan --engagement FILE runs codebud inside an engagement -- its scope guard and network fence active, every step recorded before it runs and after - it ends in an encrypted, hash-chained evidence log beside the + it ends in an encrypted, hash-chained evidence log in codebud's data + directory, out of the steps' reach; a later session must use the same engagement file. codebud-sec evidence add|verify|show|close keeps the log; codebud-sec pin is codebud integrity pin. Scanning steps run behind a network fence (pasta and nftables, optional): only their diff --git a/docs/chapters/security.texi b/docs/chapters/security.texi index ac0ddec..0660aac 100644 --- a/docs/chapters/security.texi +++ b/docs/chapters/security.texi @@ -127,9 +127,16 @@ codebud-sec pin @command{verify} checks the hash chain, and with @option{--files} that the recorded files are unchanged. @command{show} lists the entries. @command{close} ends the log: nothing can be added after it. -@command{pin} is @command{codebud integrity pin}. @option{--evidence -LOG} names a log other than the default, @file{.evidence.jsonl} -beside the engagement file. +@command{pin} is @command{codebud integrity pin}. + +The log is kept in Codebud's data directory, +@file{~/.local/share/codebud/evidence/-.jsonl}, one per +engagement file: steps never see that directory, so no step can change +the log. @option{--evidence LOG} names another place --- never one +inside the project, which steps may write. When a later session or +@command{evidence add} reopens a log, its whole chain is checked (the +passphrase is asked for that), and the engagement file must be the one +the log was opened with: a changed scope needs a new log. @node The Evidence Log diff --git a/src/codebud_sec/cli.py b/src/codebud_sec/cli.py index da42da0..be04ff2 100644 --- a/src/codebud_sec/cli.py +++ b/src/codebud_sec/cli.py @@ -20,13 +20,16 @@ - every step is recorded in the engagement's evidence log before it runs and after it ends, refused steps too (codebud.safety step recorders); a step that cannot be recorded does not run. The log is created - encrypted next to the engagement file (.evidence.jsonl) unless - --evidence names another, and reopened, its chain checked, for later - sessions. + encrypted in codebud's data directory, which steps never see + (evidence/-.jsonl), unless --evidence names another -- never + one inside the project, which steps may write. Later sessions reopen + it: its whole chain is checked (the key is unlocked for that), and the + engagement file must be the one the log was opened with. """ import argparse import hashlib +import os import sys from pathlib import Path @@ -76,15 +79,44 @@ def __call__(self, event: str, step: dict, result: dict | None) -> None: def evidence_path(engagement_file: Path, given: str | None) -> Path: + """The evidence log: ``given``, or one per engagement file in codebud's + data directory. EvidenceError for one inside the project, which steps + may write.""" + from codebud import confine, paths + if given: - return Path(given) - return engagement_file.with_name(engagement_file.stem + ".evidence.jsonl") + path = Path(given) + real = Path(os.path.realpath(path)) + project = confine.project_root() + if real == project or project in real.parents: + raise evidence.EvidenceError( + f"{given} is inside the project, where steps can write; " + "keep the evidence log outside it" + ) + return path + real = os.path.realpath(engagement_file) + tag = hashlib.sha256(real.encode()).hexdigest()[:12] + return paths.data_dir() / "evidence" / f"{engagement_file.stem}-{tag}.jsonl" def _open_log(engagement, path: Path) -> evidence.EvidenceLog: - if path.exists(): - return evidence.EvidenceLog.open(path) - return evidence.EvidenceLog.create(path, engagement=engagement, encrypt=True) + """Create the log, or reopen it: its whole chain checked, and the + engagement file the same as when the log was opened.""" + if not path.exists(): + return evidence.EvidenceLog.create(path, engagement=engagement, encrypt=True) + try: + crypto.ensure_unlocked() + except crypto.CryptoError as exc: + raise evidence.EvidenceError(f"cannot check {path}: {exc}") from exc + log = evidence.EvidenceLog.open(path) + first = evidence.entries(path)[0].get("data", {}) + if first.get("engagement_sha256") != evidence.file_sha256(engagement.path): + raise evidence.EvidenceError( + f"{engagement.path} is not the engagement file {path} was opened with " + "(it changed, or it is another one); a changed scope needs a new log " + "(--evidence NEW)" + ) + return log def _fail(message: str) -> int: @@ -101,8 +133,8 @@ def _session(args, rest: list[str]) -> int: return _fail(str(exc)) if not crypto.has_key(): return _fail("the evidence log is encrypted: create a key first (codebud key init)") - path = evidence_path(Path(args.engagement), args.evidence) try: + path = evidence_path(Path(args.engagement), args.evidence) log = _open_log(engagement, path) except evidence.EvidenceError as exc: return _fail(str(exc)) @@ -125,7 +157,11 @@ def _session(args, rest: list[str]) -> int: try: run_codebud() except SystemExit as exc: - status = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1) + if isinstance(exc.code, int) or exc.code is None: + status = exc.code or 0 + else: # a message, which Python would have printed + print(exc.code, file=sys.stderr) + status = 1 finally: sys.argv = saved safety.remove_step_recorder(recorder) @@ -137,9 +173,9 @@ def _session(args, rest: list[str]) -> int: def _log_for(args) -> tuple[Path | None, object]: try: engagement = load(args.engagement) - except EngagementError as exc: + return evidence_path(Path(args.engagement), args.evidence), engagement + except (EngagementError, evidence.EvidenceError) as exc: return None, str(exc) - return evidence_path(Path(args.engagement), args.evidence), engagement def _unlock() -> str | None: diff --git a/tests/test_codebud_sec.py b/tests/test_codebud_sec.py index d78f08d..79363df 100644 --- a/tests/test_codebud_sec.py +++ b/tests/test_codebud_sec.py @@ -63,11 +63,15 @@ def main(): return seen +def _log(case): + return cli.evidence_path(case / "acme.toml", None) + + def _entries(case): from codebud import crypto crypto.ensure_unlocked() - return evidence.entries(case / "acme.evidence.jsonl") + return evidence.entries(_log(case)) def test_a_session_runs_codebud_inside_the_engagement(case, core): @@ -82,7 +86,7 @@ def test_a_session_runs_codebud_inside_the_engagement(case, core): def test_every_step_is_in_the_encrypted_evidence_log(case, core): cli.main(["run", "--engagement", "acme.toml", "scan the web tier"]) - log = case / "acme.evidence.jsonl" + log = _log(case) assert "nmap" not in log.read_text() # sealed entries = _entries(case) kinds = [(e["kind"], e["data"].get("event") or e["data"].get("session")) for e in entries] @@ -106,12 +110,12 @@ def test_later_sessions_append_to_the_same_log(case, core): assert len(entries) == 9 -def test_the_profile_and_log_can_be_chosen(case, core): - cli.main( - ["chat", "--engagement", "acme.toml", "--profile", "security-ir", "--evidence", "ir.jsonl"] - ) +def test_the_profile_and_log_can_be_chosen(case, core, tmp_path_factory): + elsewhere = tmp_path_factory.mktemp("cases") / "ir.jsonl" + argv = ["chat", "--engagement", "acme.toml", "--profile", "security-ir"] + cli.main([*argv, "--evidence", str(elsewhere)]) assert core["argv"][:3] == ["codebud", "--profile", "security-ir"] - assert (case / "ir.jsonl").exists() and not (case / "acme.evidence.jsonl").exists() + assert elsewhere.exists() and not _log(case).exists() def test_codebuds_exit_status_is_kept_and_recorded(case, core): @@ -204,7 +208,6 @@ def main(): assert seen["argv"] == ["codebud", "integrity", "pin", "--command", "nmap"] - def test_a_scope_refusal_gets_its_own_hint(): """Not the allow-list advice: no allow-list widens an engagement.""" from codebud.cli.errors import explain @@ -215,3 +218,39 @@ def test_a_scope_refusal_gets_its_own_hint(): ) explanation, fix = explain(error) assert "engagement" in explanation and "harmless_commands" not in fix + + +# --- review of #23 ----------------------------------------------------------------- + + +def test_the_log_is_kept_where_steps_cannot_write(case, core): + """In codebud's data directory: bwrap hides it from steps, and the file + tools are confined to the project.""" + from codebud import confine, paths + + cli.main(["run", "--engagement", "acme.toml", "x"]) + log = _log(case) + assert log.exists() and paths.data_dir() in log.parents + with pytest.raises(confine.ConfinementError): + confine.check_write(str(log)) + + +@pytest.mark.parametrize("inside", ["case.jsonl", "logs/case.jsonl", "."]) +def test_a_log_inside_the_project_is_refused(case, core, inside): + assert cli.main(["run", "--engagement", "acme.toml", "--evidence", inside, "x"]) == 2 + assert "argv" not in core + + +def test_a_changed_engagement_file_is_refused_for_an_existing_log(case, core, capsys): + cli.main(["run", "--engagement", "acme.toml", "x"]) + (case / "acme.toml").write_text(SCOPE.replace("10.0.0.0/16", "0.0.0.0/0")) + core.clear() + assert cli.main(["run", "--engagement", "acme.toml", "x"]) == 2 + assert "argv" not in core and "new log" in capsys.readouterr().err + assert cli.main(["evidence", "add", "acme.toml", "--engagement", "acme.toml"]) == 2 + + +def test_a_message_codebud_exits_with_is_shown(case, core, capsys): + core["exit"] = "codebud: the integrity manifest does not match" + assert cli.main(["run", "--engagement", "acme.toml", "x"]) == 1 + assert "manifest does not match" in capsys.readouterr().err diff --git a/tests/test_layers.py b/tests/test_layers.py index cf641ad..46bfc0a 100644 --- a/tests/test_layers.py +++ b/tests/test_layers.py @@ -16,9 +16,11 @@ # The core modules codebud_sec may import. CORE_API = { "codebud.cli.main", # codebud-sec hands its sessions over to codebud (PR 7) + "codebud.confine", # the evidence log is never inside the project (review of #23) "codebud.crypto", # encryption at rest: the evidence log (D14b) "codebud.fsutil", "codebud.isolation", # the scope guard needs isolated steps (review of #18) + "codebud.paths", # the evidence log's default place, codebud's data dir (review of #23) "codebud.privacy", # the scope guard refuses an engagement at privacy trusted "codebud.safety", # the scope guard registers its step check (D14i, PR 4) }