Repository navigation
SOTA round 2: signed audit, real sandboxing, native tool use, gate hardening - #11
Conversation
The hash chain is unkeyed, so a log rewritten wholesale with a consistent chain verified as VALID. With SENTINEL_AUDIT_KEY set, every audit event is now followed by a signed checkpoint in <log>.sig, and 'verify-audit --pubkey' rejects rewritten chains, foreign keys and truncated logs. Adds 'audit-keygen' and docs/SOTA_ROADMAP.md for round 2. Tests: 454 -> 473. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
…n production (ADR-016) Production handed capabilities RealCommandExecutor, which has no allowlist, timeout or rlimits; the documented protections lived in CommandExecutor and were never in the path. All four call sites now use HardenedExecutor: exact-match allowlist, timeout, rlimits, no_new_privs, kill-on-drop. read_only_paths / writable_paths are now enforced with Landlock (raw syscalls, ruleset built in the parent, async-signal-safe pre_exec). Not yet enabled for built-in capabilities; needs per-capability profiles. CI: pin toolchain to 1.97.0. Rust 1.99 clippy fails untouched code via double_must_use in async-trait's expansion. Tests: 473 -> 486. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
…injection Every command a built-in capability spawns now declares what it may write (FsAccess) and HardenedExecutor enforces it per call with Landlock. SENTINEL_LANDLOCK=off|require selects the mode; default is best effort. Security fix: log_vacuum split find output on newlines, so a directory named 'evil\n' holding a copy of an absolute path redirected 'rm -f' to any *.log file on the host. Output is now NUL-separated and paths must sit under log_dir. Tests: 486 -> 497. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
deny_network was a warning. It is now a seccomp filter denying AF_INET, AF_INET6 and AF_PACKET sockets and io_uring_setup; unix and netlink sockets keep working. HardenedExecutor applies it to every command except the package managers. Children no longer inherit Sentinel's environment: fixed PATH, C locale, and overrides that change loaded code (PATH, LD_*, BASH_ENV, ...) refused. An inherited or injected PATH can no longer redefine an allowlisted name. Tests: 497 -> 506. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
Investigation and planning go through tools/tool_use instead of a JSON object parsed out of response text. One tool per capability with its argument schema, plus done_investigating and propose_plan; exactly one call per turn; text is never executed. Tool calls are converted back through the existing parsers so validation is identical on both paths. Adds Capability::args_schema() (all 14 built-ins), LlmBackend:: complete_with_tools, and SENTINEL_NATIVE_TOOLS=off as the fallback switch. Tested with wiremock and fakes only; not sent to the live API. Tests: 506 -> 527. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
Gate review — reviewer: claudeLanes: reviewed by claude
Review of the diff, read-only, as given. Findings1. The filter changed from
Withdrawing this one; the stated concern does not survive tracing. (Flagging only the real remainder below.) 2.
3. 4. Real finding: let mut sandbox = sandbox.clone();
if !network && self.landlock != LandlockMode::Off {
sandbox.deny_network = true;
}
5. Confirmed finding: 6. Confirmed finding: let ((stdout, stderr, truncated), waited) = tokio::join!(
capture.capture(stdout_pipe, stderr_pipe),
guard.wait_with_timeout()
);
let (status, _timed_out) = waited?;
7. Confirmed finding: the let map_err = |e: nix::errno::Errno| std::io::Error::from_raw_os_error(e as i32);This is fine for real errnos. Minor, not worth a finding on its own. 8. Confirmed finding: CI's clippy annotation step cannot fail and can silently annotate nothing — The 9. Confirmed finding: The SignatureVerification {
...
unsigned_tail: total - covered,
error: None,
}
10. Confirmed finding: 11. Real finding:
12. Confirmed finding: Not a bug; documented switch. Not a finding. Let me state only what survived verification, cleanly. ReportFindings is not applicable here (this is a gate review, not the code-review tool path), so the findings are above in prose. Three survive:
The audit-signing work ( VERDICT: REVISE — VERDICT: REVISE — |
OpenAI: function calling with tool_choice required and parallel calls off; default on for api.openai.com, opt-in for OpenAI-compatible servers. Malformed arguments fail the turn. Ollama: tool calling, opt-in per backend since support depends on the model; no tool_choice, so a text answer fails the turn. Tested with wiremock only; no live requests. Tests: 527 -> 538. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
--policy FILE / SENTINEL_POLICY loads operator rules and resource guards from TOML for run, tui, serve --mcp, execute and policy. In the default tighten mode file rules can only make the built-in decision stricter; allow is rejected at load. mode = "replace" swaps the built-in rules and warns on every start. Kill switch, built-in guards and deny-by-default are not configurable. Strict parsing; a bad or loosely-permissioned file stops the process instead of falling back. Tests: 538 -> 552. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
execute read Approved, then wrote Executing: two processes started together both passed the check and both ran the plan. Status transitions now run under an exclusive flock on a per-plan lock file, and Approved -> Executing is one atomic claim. approve/reject race closed the same way. Plan writes are fsynced before and after the rename. Race tests fail every time with the lock disabled and pass 15/15 with it. Tests: 552 -> 556. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
Two executors had drifted. The loop's (sentinel run, TUI) skipped every step policy marked RequiresApproval even in an approved plan, so approved Medium-risk mutating steps never ran; it marked a step Completed when the capability returned a Failure result; and it marked steps RolledBack when the inverse failed or did not exist. New sentinel-runner crate holds the only executor: approval covers the plan, Denied never runs, policy uses the current manifest, halt on first failure, policy-checked reverse-order rollback, audit before action. --no-rollback / SENTINEL_NO_ROLLBACK on every path. Tests: 556 -> 573. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
ResilientBackend wraps any LlmBackend: up to four attempts on rate limits, 5xx/529, network errors and timeouts, with jittered exponential backoff and Retry-After honoured (waits over 60s refused); a deadline per attempt; and a hard per-session cap on model calls and tokens checked before each request. Provider response bodies are now read with an 8 MiB cap. Wired into sentinel run and the TUI. Tests: 573 -> 587. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
…, GenAI spans (ADR-021) SentinelMetrics (ADR-011) was defined and tested but nothing incremented or exported it. Counters are now derived from every audit append, so all commands are measured and the numbers match the chain. Adds LLM request, token, retry and duration metrics from ResilientBackend, a bounded-label MCP tool counter, and SENTINEL_METRICS_FILE (atomic Prometheus text file). Adds gen_ai.chat and gen_ai.execute_tool spans with OpenTelemetry GenAI semantic-convention attributes. Prompts, completions and tool arguments are never attached. No OTLP exporter is bundled. Tests: 587 -> 597. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
…njection routes (ADR-022) sentinel fleet invoked 'sentinel agent-exec' on each host, a subcommand that did not exist, so it could not work; and it consulted no policy, asked for no approval and wrote no audit event. - agent-exec implemented: runs one capability under the host's own policy, audited; Denied is refused, RequiresApproval needs --approved. - Controller evaluates policy per host, dispatches nothing without --approve when approval is required, and audits the run. - --hosts '-oProxyCommand=...' ran a local command via ssh option parsing: hosts, users and key paths are validated and ssh is called with '--'. - --capability was interpolated unquoted into the remote shell: validated and quoted. - Fingerprint pin compared as raw bytes in constant time. - AgentClient stubs return NotImplemented instead of Ok. Tests: 597 -> 612. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
… CI test failures One of the two CI runs for the previous commit failed in 'cargo test' while the other passed. The fleet tests write an executable and spawn processes from parallel threads, which can fail with ETXTBSY; they now run under one lock. CI also reports failing test names as check annotations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
The span tests installed a thread-local tracing subscriber. tracing caches per-callsite interest globally, so a callsite first reached by another test thread without a subscriber could stay disabled, and the span was never recorded: one CI run in two failed. They now live in their own test binary with a single process-wide subscriber and look up spans by a per-test model name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
New Gate tab lists stored plans (pending first), shows steps, risk, hash and integrity, and approves (typed plan-id prefix) or rejects (y). Approve and reject logic moved to sentinel_mcp::operator and is shared with the CLI, so checks and audit events are identical; the surface is recorded as operator_cli or operator_tui. Fixes: on the Goal tab q quit and a/s/r/j/k were swallowed or scrolled, so goals like 'restart nginx' could not be typed; and typing or deleting a non-ASCII character panicked. Tests: 612 -> 625. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
…advisories - cargo deny check (advisories, licences, sources, wildcards) as a CI job, configured in deny.toml. Its first run found four advisories: rustls, h2, crossbeam-epoch and anyhow are updated past the fixed versions. - rustls-pemfile (unmaintained, RUSTSEC-2025-0134) removed; fleet PEM parsing uses rustls-pki-types' PemObject. - Every action pinned to a commit SHA; workflows default to a read-only token; --locked builds. - Release: binaries built with the pinned toolchain instead of stable, CycloneDX SBOM per target, build provenance attestation. - sentinel-tui/tests/supply_chain.rs guards all of the above. - docs/SUPPLY_CHAIN.md: controls, how to respond to failures, toolchain bump procedure. Toolchain bump itself split out as 13b. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
- proptest suites for the policy evaluator, the untrusted-data fence, audit-chain verification and the MCP JSON-RPC surface (32 properties). - Resource guards (roadmap 14b): inspect every string argument at any depth, normalise paths lexically, match unit names without suffix or instance. log_dir / cache_dirs / sshd.service / ssh / /var/../etc no longer bypass them. Default guards extended to the directories SECURITY.md already claimed, plus ssh, systemd-*, containerd. - Fuzzer finding: a float in MCP tool arguments could parse back from disk one bit off, so an untouched audit log failed verification. Fixed with serde_json's float_roundtrip. - Roadmap: 14 and 14b ticked; 13b (toolchain bump) still blocked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
- Version 0.2.0 (not tagged). CHANGELOG sections merged under [0.2.0]. - README: features, CLI reference, environment variables, policy-file format and architecture now match the binary. Removed the Ollama CLI example (the CLI accepts only anthropic and openai) and the claim that fleet traffic uses mTLS (it runs over SSH). - SECURITY.md: fleet, plan execution, MCP gate, LLM and policy-file sections rewritten; supported versions; open limitations listed. - ADRs: index added; ADR-008 and ADR-011 carry implementation notes; ADR-013 marked Accepted. - Roadmap: item 15 ticked. 13b, 14c, 14d, 14e, 14f remain open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
Gate review — reviewer: claudeLanes: reviewed by claude
I reviewed the diff as-built. Findings, most severe first. 1.
|
… test can actually fail - log_vacuum: find, prefix filter and the Landlock allowlist all operate on the component-normalised log_dir; found paths are normalised the same way with '..' rejected anywhere - the CHANGELOG's 'every path sits under log_dir' claim now holds in this filter, not one layer up in validate_args. Unit tests cover both sides of the boundary. - seccomp: the jump-offset test walks every conditional jump and asserts jt/jf targets are in-bounds RET instructions (a length assertion could not fail for an off-by-one); cfg(test) insns() accessor added - audit: the checkpoint comment now states the real contract - the event is durable before signing, so the error means 'recorded but unsigned', detectable by verify-audit
Gate review — reviewer: claudeLanes: reviewed by claude
I'm in review mode: diff-only, read-only, no tools. Here are my findings. Findings1.
Failure scenario: 2. The BPF reads 3.
4. The comment at line 150 is explicit and honest about the contract ("recorded but unsigned"), and I agree aborting is the right direction. The defect is narrower: when the sidecar write fails, 5.
6. Minor and probably intended; noting only because the new early-return at line 148 captures every 7.
8. The test is a real improvement over a length assertion and does catch off-by-one Not findings, verified as sound
Findings 1 and 3 are the ones I'd want addressed before merge: finding 1 because the CHANGELOG and the code comment both assert the vacuum boundary holds at the enforcement point and it still depends on VERDICT: REVISE — VERDICT: REVISE — |
…closed under Require - normalise_components returns None for relative input instead of quietly absolutising it - the vacuum boundary never leans on validate_args' absolute-path rule again - sandbox_for: under SENTINEL_LANDLOCK=require the Unrestricted profile carries require_enforcement, so a kernel that cannot apply the base config's enforcement (deny_network etc.) refuses to spawn rather than warning and running; test asserts the flag
Gate review — reviewer: claudeLanes: reviewed by claude
Two findings, both reported above. Summary:
Things I checked and cleared, for the record: the VERDICT: REVISE — new seccomp jump-target test asserts that both branches of every conditional jump land on a RET, which is false for the filter in this diff (instruction 6's VERDICT: REVISE — new seccomp jump-target test asserts that both branches of every conditional jump land on a RET, which is false for the filter in this diff (instruction 6's |
… - lane rule) Both-branches-land-on-RET was wrong for this filter: the socket branch's jt falls through to the ARG0 load. Enforceable invariants instead: every jt/jf target in bounds, the six documented as-built targets (any offset edit fails with a readable message), and the three terminals are RET instructions.
Gate review — reviewer: claudeLanes: reviewed by claude
This is a review request. Let me work through the diff. Key areas I examined closely: 1. FsAccess::Unrestricted => {
let mut base = self.sandbox.clone();
if self.landlock == LandlockMode::Require {
base.require_enforcement = true;
}
return base;
}The base sandbox from 2.
One real gap: the prefix check uses 3. The comment documents the contract explicitly: event already appended, 4. let count = |label: &str| ...;The closure referenced in the diff hunk counts over 5. The test asserts in-bounds for all jt/jf and pins the six documented targets plus three RET terminals. Walking the program: instruction 4 is 6. while let Some(stripped) = SUFFIXES.iter().find_map(|s| unit.strip_suffix(s)) {
unit = stripped.trim().to_string();
}Terminates: each iteration strictly shortens. 7. Duration::from_nanos((exp.as_nanos() as u64 / 1000).saturating_mul(scale))
8.
9. Guarded by 10. And the unsigned path returns early before the key parse. The 11.
12. Missing-test check on the
I found no defect that survives verification. A few things I considered and dismissed as non-defects: the The diff is large but internally consistent: each security claim in the CHANGELOG has a corresponding enforcement point and a test that fails without it, the gate-round follow-ups (r1/r2/r3) moved checks to the enforcement point rather than relying on a caller one layer up, and the new verification (property tests, seccomp jump-target test, race tests) is constructed so it can actually fail. VERDICT: APPROVED VERDICT: APPROVED |
Four formatting hunks CI caught (rustc is broken on the authoring Mac): the traversal assert chain, the jt/jf tuple loop, the jump-count assert, and the two golden-target asserts - exactly the forms rustfmt wants, no semantic change.
* fix ci after #11: rustfmt rewraps in the round-4 test code Four formatting hunks CI caught (rustc is broken on the authoring Mac): the traversal assert chain, the jt/jf tuple loop, the jump-count assert, and the two golden-target asserts - exactly the forms rustfmt wants, no semantic change. * fix ci: ambiguous .into() on failure()'s impl Into<String> param The traversal-rejection arm used "...'...'.into()" against an impl Into<String> parameter - the target type cannot be inferred (E0283). Pass the &str directly; &str implements Into<String>.
* fix ci after #11: rustfmt rewraps in the round-4 test code Four formatting hunks CI caught (rustc is broken on the authoring Mac): the traversal assert chain, the jt/jf tuple loop, the jump-count assert, and the two golden-target asserts - exactly the forms rustfmt wants, no semantic change. * fix ci: ambiguous .into() on failure()'s impl Into<String> param The traversal-rejection arm used "...'...'.into()" against an impl Into<String> parameter - the target type cannot be inferred (E0283). Pass the &str directly; &str implements Into<String>. * fix ci: clippy denies in the vacuum filter chain redundant closure -> pass normalise_components directly; drop the .map(String::from) the iterator no longer needs (filter_map already yields String).
* fix ci after #11: rustfmt rewraps in the round-4 test code Four formatting hunks CI caught (rustc is broken on the authoring Mac): the traversal assert chain, the jt/jf tuple loop, the jump-count assert, and the two golden-target asserts - exactly the forms rustfmt wants, no semantic change. * fix ci: ambiguous .into() on failure()'s impl Into<String> param The traversal-rejection arm used "...'...'.into()" against an impl Into<String> parameter - the target type cannot be inferred (E0283). Pass the &str directly; &str implements Into<String>. * fix ci: clippy denies in the vacuum filter chain redundant closure -> pass normalise_components directly; drop the .map(String::from) the iterator no longer needs (filter_map already yields String). * fix ci: drop the now-unused cfg(test) len() accessor The rewritten seccomp test computes length from insns(); the old len() accessor lost its only caller and dead_code denies the build.
Sentinel 0.2.0 — release summary
Draft. Not merged and not tagged — both wait for the maintainer.
20 commits on
feat/sota-round-2, 101 files, +14,592 / −1,090. Workspace tests 454 → 667 (1 ignored), all passing.cargo fmt --check,clippy -D warnings,cargo test --workspaceandcargo deny checkare green locally and in CI at42adf57.What shipped
audit-keygen,verify-audit --pubkeyno_new_privsPATH--policy FILE,tightenandreplacemodessentinel-runner) forrun, the TUI andexecute; rollback in reverse orderagent-execcargo-denyin CI, pinned action SHAs, read-only tokens, SBOM and build provenancedocs/SUPPLY_CHAIN.mdDefects found and fixed along the way
log_vacuumcould delete arbitrary files via a newline in a file name.sentinel runnever performed approved Medium-risk mutating steps, and reported failed steps as completed.sentinel fleetconsulted no policy, wrote no audit event, and had two injection routes (--hosts→ local command execution,--capability→ remote shell injection).log_dir,cache_dirs,sshd.service,ssh,/var/../etcand//etc; the default path guard protected fewer directories thanSECURITY.mdsaid.q,a,s,r,j,kand panicked on non-ASCII input.rustls,h2,crossbeam-epoch,anyhow);rustls-pemfileremoved.Still open (unchecked in
docs/SOTA_ROADMAP.md)double_must_usefires inasync-traitoutput. Needs a machine that can install 1.99.Executingplans. A process killed mid-run leaves its planExecuting; nofail-plancommand yet.tracingonly.sentinel fleet, approval travels as a flag, and the unused mTLS controller code is still in the tree.Not verified
wiremockand fakes only; nothing was sent to a live API (no paid calls were made).ssh, not a real SSH server.systemctland package-manager Landlock profiles were not run against live systemd or package managers.cargo-fuzzneeds nightly).Before merging
SECURITY.md→ Known Limitations; it lists everything above.[0.2.0] - Unreleased; set the date when tagging.🤖 Generated with Claude Code
https://claude.ai/code/session_011quWn3GSjo8BNBbMo65ysi
Generated by Claude Code