v3.11 — sysadmin: read-only log diagnosis (discover → collect → synthesize) - #11
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a sysadmin tool: a deterministic three-step graph (discover → collect → synthesize, one call, no done:false) that diagnoses real service/system problems from actual logs. Read-only, always — there is no restart/stop/mutate path anywhere in the code, by design, same posture as the git tool.
Triggered from chat like any other tool ("le service X plante en boucle, tu peux regarder ?"), no new UI surface, consistent with the zero-tab constraint.
What's new
graphs/sysadmin.py — discover (active systemd units + podman containers) → collect (logs for a validated target, or kernel logs as fallback) → synthesize (one LLM call, plain-text diagnosis + proposed fix, never an executed action)
tools/sysadmin.py — chat-dispatchable JSON wrapper ({"target_hint": "...", "question": "..."}), same contract shape as review/research
Router entries — description + few-shot examples in router/prompt.py, including explicit contrast against files and shell to avoid the ambiguity classes hit in v3.9/v3.10
UI: expandable per-step detail (forge/subtrace.py) — any graph-based tool (review/research/sysadmin) can now publish its internal node steps to the Traces panel without widening the strict tool.run() -> str contract; a step pill with sub-steps gets a caret, click to expand
Read-only host access, three independent proxies (deploy/) — never the raw sockets:
journalctl — plain bind mount (/var/log/journal:ro), no daemon involved
systemctl/busctl — xdg-dbus-proxy (same tool Flatpak uses), deny-by-default, only 5 read-only method calls allowed
podman logs/ps — a small stdlib-only HTTP proxy (podman_ro_proxy.py), GET-only on two endpoints, 403 on everything else
deploy/setup-sysadmin-host-access.sh — one-shot idempotent installer (systemd --user units, persists across reboots)
deploy/ansible/sysadmin-proxies.yml — starting point for provisioning a new host
Why proxies instead of raw sockets + a confirmation step
A confirmation in Forge's own code only guards the path Forge chooses to take. Once a socket is reachable from inside the container, anything else running there (a future bug, a compromised dependency, any code-execution path) can reach it directly, bypassing the confirmation entirely. The three proxies make the mutating calls structurally unavailable regardless of what code runs in the container — full reasoning in deploy/README.md.
Real production bugs found and fixed this cycle
Left in as regression tests, not just fixed silently:
Prompt-injection-shaped example leak — the model copied the GOOD ANSWER few-shot example verbatim as a fabricated diagnosis for an unrelated question; fixed with a leak-fragment detector plus a rewritten, deliberately non-copiable placeholder example
Context overflow crash (4362 > 4096 tokens) — SYSADMIN_MAX_LOG_LINES alone didn't bound prompt size; added a hard character budget (SYSADMIN_LOG_CHARS_BUDGET) independent of line count
[error] ... strings parsed as real data — both a Python-exception path and a nonzero-exit-code path could slip a raw error message through as if it were a discovered unit/container name
systemctl silently ignoring DBUS_SYSTEM_BUS_ADDRESS — confirmed via SYSTEMD_LOG_LEVEL=debug that it hardcodes an attempt at /run/systemd/private first with no fallback; switched discovery to busctl, which has none of that quirk (--json=short, real parseable output)
Rootless podman not passing host supplementary groups — journalctl -u on root-owned services needs wheel/adm group membership inside the container; fixed with --group-add keep-groups (run.oci.keep_original_groups: "1" in compose)
Testing
389 tests passing (344 pre-existing + 45 new), ruff check/ruff format --check clean. Notably includes a mutation-tested security check (test_sysadmin_never_passes_unvalidated_name_to_subprocess) — verified to actually fail against a deliberately broken validation before being trusted as a real guard, not just a test that passes by construction.
Deployment
bash
./deploy/setup-sysadmin-host-access.sh # one-time, idempotent
then mount the proxies + journal and add sysadmin to ENABLED_TOOLS — see README.md and deploy/README.md for the full commands (podman run and podman-compose both covered).
Follow-ups
None blocking — the security audit discussed alongside this PR is tracked separately, not part of this scope.