sec(gate): every denial names its rule; write tools are judged by path; the container tier records denials
Rule ids on the gate's rules, recorded on every denial as
{"rule":"<id>","payload":<hook event>} and read back by
vm_tool_gate::denial_detail into gate.denied events, so 'which rule fires
and how often' is a query on detail->>'rule'. Older bare-payload lines
still parse, with no rule.
A write-path policy: Write/Edit/MultiEdit/NotebookEdit are refused when
aimed at the hooks, their records, or the settings that install them, on
either tier (/root/toolgate, /root/toolhooks, /root/tap, the guest and
.claude settings, and .git/hooks/ — a hook in the checkout runs with the
agent's authority unseen). An agent could Write over denied.jsonl and erase
its own denials; now it cannot, and the same paths are refused to Bash
whatever the tool in front of them (sed -i is a text tool that writes).
The extractor prints file_path as a third line; the predicate and the
generated shell are tested against the same payloads, and the shell test
now reads the record back.
The container tier drained the tap and the inert marker and never the
denials: gate.denied existed only for microVM phases. drain_denied fixes
that. Harness: a gatepolicy scenario with two negative controls on the
container tier, asserting the two rule ids in the record.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
co-authored by
Claude Opus 5
parent
13f7fb3aff
commit
3909fa14ca
@@ -186,6 +186,9 @@ pub const GATE_ABSENT: &str = "gate.absent";
|
||||
/// [`crate::vm_tool_gate::INERT_FILE`] — this is the reader that marker was
|
||||
/// missing in production; until now only a unit test looked for it.
|
||||
pub const GATE_INERT: &str = "gate.inert";
|
||||
/// One call the gate refused. `detail` is the hook event with `rule` set
|
||||
/// beside it — see [`crate::vm_tool_gate::denial_detail`]. Both tiers.
|
||||
pub const GATE_DENIED: &str = "gate.denied";
|
||||
|
||||
/// Write the install outcome into the mission record.
|
||||
pub async fn record_install(
|
||||
@@ -234,6 +237,32 @@ pub async fn drain_inert(docker: &Docker, container: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate's denial record inside the mission container.
|
||||
pub fn denied_file() -> String {
|
||||
format!("{HOOK_DIR}/{}", crate::vm_tool_gate::DENIED_FILE)
|
||||
}
|
||||
|
||||
/// Every call the gate refused since the last drain, one JSON line each
|
||||
/// (`vm_tool_gate::denial_detail` reads them). Read-then-truncate, like
|
||||
/// [`drain`], for the same reason: no cursor to keep, and the phase has
|
||||
/// finished so nothing is appending.
|
||||
pub async fn drain_denied(docker: &Docker, container: &str) -> Vec<String> {
|
||||
let file = denied_file();
|
||||
let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true");
|
||||
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
|
||||
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
||||
{
|
||||
Ok(out) => out
|
||||
.stdout
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tap file inside the mission container.
|
||||
pub fn tap_file() -> String {
|
||||
format!("{TAP_DIR}/tools.jsonl")
|
||||
|
||||
Reference in New Issue
Block a user