Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions crates/openshell-supervisor-network/src/policy_local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ const MAX_DENIALS_LIMIT: usize = 100;
/// denials.
const DENIAL_LOG_FILES_TO_SCAN: usize = 2;
const LOG_DIR: &str = "/var/log";
/// Shorthand log filenames are `openshell.YYYY-MM-DD.log`. The trailing dot in
/// the prefix is intentional: it disambiguates from the OCSF JSONL appender's
/// Read current and legacy shorthand filenames. The trailing dots in the
/// prefixes disambiguate from the OCSF JSONL appender's
/// `openshell-ocsf.YYYY-MM-DD.log`, which we never want to surface here (the
/// JSONL is opt-in via `ocsf_json_enabled` and not the source of truth for
/// `/v1/denials`).
const SHORTHAND_LOG_PREFIX: &str = "openshell.";
const SHORTHAND_LOG_PREFIXES: [&str; 2] = ["openshell-text.", "openshell."];
/// Defensive cap on per-line length returned to the agent so a pathological
/// log entry (very long URL path, etc.) cannot blow up the response.
const MAX_DENIAL_LINE_BYTES: usize = 4096;
Expand Down Expand Up @@ -342,7 +342,7 @@ async fn recent_denials_response(
});
if !log_available {
payload["note"] = serde_json::json!(
"no shorthand log file is present yet at /var/log/openshell.YYYY-MM-DD.log; the supervisor may not have emitted any events to disk yet"
"no shorthand log file is present yet at /var/log/openshell-text.YYYY-MM-DD.log; the supervisor may not have emitted any events to disk yet"
);
}

Expand Down Expand Up @@ -469,9 +469,11 @@ fn collect_shorthand_log_files(log_dir: &Path, max_files: usize) -> std::io::Res
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
// `openshell.YYYY-MM-DD.log` only — the trailing dot in the prefix
// disambiguates from `openshell-ocsf.YYYY-MM-DD.log`.
if !name.starts_with(SHORTHAND_LOG_PREFIX) || !name.ends_with(".log") {
if !SHORTHAND_LOG_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
|| !name.ends_with(".log")
{
return None;
}
let modified = entry.metadata().and_then(|m| m.modified()).ok()?;
Expand Down Expand Up @@ -1615,7 +1617,7 @@ mod tests {
#[tokio::test]
async fn recent_denials_returns_newest_first_from_shorthand_lines() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("openshell.2026-05-06.log");
let log_path = dir.path().join("openshell-text.2026-05-06.log");
// Mixed file: allowed events, non-OCSF info lines, two denials.
// Lines are written in chronological order; reader walks newest-first.
let body = "\
Expand Down Expand Up @@ -1679,6 +1681,34 @@ mod tests {
assert_eq!(payload["denials"].as_array().unwrap().len(), 0);
}

#[test]
fn shorthand_file_selection_includes_legacy_and_excludes_jsonl() {
let dir = tempfile::tempdir().unwrap();
let filenames = [
"openshell.2026-05-05.log",
"openshell.2026-05-06.log",
"openshell-text.2026-05-07.log",
"openshell-ocsf.2026-05-07.log",
"openshell-text.2026-05-07.log.bak",
];
for (index, filename) in filenames.iter().enumerate() {
let path = dir.path().join(filename);
std::fs::write(&path, b"log entry").unwrap();
let modified = std::time::UNIX_EPOCH
+ std::time::Duration::from_secs(u64::try_from(index).unwrap());
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(modified))
.unwrap();
}
assert_eq!(
collect_shorthand_log_files(dir.path(), DENIAL_LOG_FILES_TO_SCAN).unwrap(),
vec![dir.path().join(filenames[2]), dir.path().join(filenames[1])]
);
}

#[tokio::test]
async fn recent_denials_signals_when_log_is_missing() {
let dir = tempfile::tempdir().unwrap();
Expand All @@ -1698,7 +1728,7 @@ mod tests {
payload["note"]
.as_str()
.unwrap()
.contains("/var/log/openshell.")
.contains("/var/log/openshell-text.")
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ The new submission wins by structural overlap.
Two local files complement the API and are useful when debugging policy
behavior:

- `/var/log/openshell.YYYY-MM-DD.log` — shorthand log of sandbox activity.
- `/var/log/openshell-text.YYYY-MM-DD.log` — shorthand log of sandbox activity.
Older supervisors use `/var/log/openshell.YYYY-MM-DD.log`.
This is what `/v1/denials` reads from.
- `/var/log/openshell-ocsf.YYYY-MM-DD.log` — full OCSF JSON events, only
written when the `ocsf_json_enabled` setting is on. Not used by
Expand Down
50 changes: 25 additions & 25 deletions crates/openshell-supervisor/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

//! `OpenShell` supervisor executable.

mod rolling_logs;

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
Expand Down Expand Up @@ -310,17 +312,16 @@ fn main() -> Result<()> {
None
};

let file_logging = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("openshell")
.filename_suffix("log")
.max_log_files(3)
.build("/var/log")
.ok()
.map(|roller| {
let (writer, guard) = tracing_appender::non_blocking(roller);
(writer, guard)
});
let file_logging = rolling_logs::appender(
"/var/log",
rolling_logs::SHORTHAND_PREFIX,
tracing_appender::rolling::Rotation::DAILY,
)
.ok()
.map(|roller| {
let (writer, guard) = tracing_appender::non_blocking(roller);
(writer, guard)
});
let console_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level));
let runtime = tokio::runtime::Builder::new_multi_thread()
Expand Down Expand Up @@ -349,20 +350,19 @@ fn main() -> Result<()> {
let ocsf_schema_version = Arc::new(std::sync::Mutex::new(String::new()));

let (_file_guard, _jsonl_guard) = if let Some((file_writer, file_guard)) = file_logging {
let jsonl_logging = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("openshell-ocsf")
.filename_suffix("log")
.max_log_files(3)
.build("/var/log")
.ok()
.map(|roller| {
let (writer, guard) = tracing_appender::non_blocking(roller);
let layer = OcsfJsonlLayer::new(writer)
.with_enabled_flag(ocsf_enabled.clone())
.with_target_version(ocsf_schema_version.clone());
(layer, guard)
});
let jsonl_logging = rolling_logs::appender(
"/var/log",
rolling_logs::OCSF_PREFIX,
tracing_appender::rolling::Rotation::DAILY,
)
.ok()
.map(|roller| {
let (writer, guard) = tracing_appender::non_blocking(roller);
let layer = OcsfJsonlLayer::new(writer)
.with_enabled_flag(ocsf_enabled.clone())
.with_target_version(ocsf_schema_version.clone());
(layer, guard)
});
let (jsonl_layer, jsonl_guard) =
jsonl_logging.map_or((None, None), |(layer, guard)| (Some(layer), Some(guard)));
tracing_subscriber::registry()
Expand Down
146 changes: 146 additions & 0 deletions crates/openshell-supervisor/src/rolling_logs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::path::Path;
use tracing_appender::rolling::{InitError, RollingFileAppender, Rotation};

// Retention uses starts_with(prefix), so neither prefix may start with the other.
pub const SHORTHAND_PREFIX: &str = "openshell-text";
pub const OCSF_PREFIX: &str = "openshell-ocsf";

pub fn appender(
directory: impl AsRef<Path>,
prefix: &str,
rotation: Rotation,
) -> Result<RollingFileAppender, InitError> {
RollingFileAppender::builder()
.rotation(rotation)
.filename_prefix(prefix)
.filename_suffix("log")
.max_log_files(3)
.build(directory)
}

#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::io::Write as _;
use std::time::{Duration, Instant};

fn history(directory: &Path, prefix: &str) -> BTreeMap<OsString, Vec<u8>> {
std::fs::read_dir(directory)
.unwrap()
.map(Result::unwrap)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(&format!("{prefix}."))
})
.map(|entry| (entry.file_name(), std::fs::read(entry.path()).unwrap()))
.collect()
}

fn seed_history(directory: &Path, prefix: &str, minutely: bool) {
for day in 1..=3 {
let date = format!("2000-01-{day:02}");
let date = if minutely {
format!("{date}-00-00")
} else {
date
};
let filename = format!("{prefix}.{date}.log");
std::fs::write(directory.join(&filename), filename.as_bytes()).unwrap();
}
}

#[test]
fn retention_prefixes_are_disjoint_before_timestamp_selection() {
// The prefix checks precede both metadata.created() and the filename
// date fallback, so this invariant holds on either kind of filesystem.
assert!(!SHORTHAND_PREFIX.starts_with(OCSF_PREFIX));
assert!(!OCSF_PREFIX.starts_with(SHORTHAND_PREFIX));
}

#[test]
fn daily_initialization_retains_each_history_independently() {
for prefixes in [
[SHORTHAND_PREFIX, OCSF_PREFIX],
[OCSF_PREFIX, SHORTHAND_PREFIX],
] {
let directory = tempfile::tempdir().unwrap();
for prefix in prefixes {
seed_history(directory.path(), prefix, false);
}
let legacy = directory.path().join("openshell.1999-12-31.log");
std::fs::write(&legacy, b"legacy shorthand").unwrap();
// Report whether this run exercises the original bug's creation-
// timestamp path; don't silently skip on filesystems without it.
eprintln!(
"log filesystem exposes creation timestamps: {}",
std::fs::metadata(&legacy).unwrap().created().is_ok()
);

for (prefix, other) in [(prefixes[0], prefixes[1]), (prefixes[1], prefixes[0])] {
let other_history = history(directory.path(), other);
let mut writer = appender(directory.path(), prefix, Rotation::DAILY).unwrap();
writer.write_all(b"new event\n").unwrap();
writer.flush().unwrap();
assert_eq!(history(directory.path(), other), other_history);
assert_eq!(history(directory.path(), prefix).len(), 3);
assert_eq!(std::fs::read(&legacy).unwrap(), b"legacy shorthand");
}
}
}

#[test]
fn rollover_retains_each_history_independently() {
// MINUTELY and DAILY share the same pruning path. Use a real minute
// boundary because tracing-appender's mock clock is private to its tests.
let directory = tempfile::tempdir().unwrap();
for prefix in [SHORTHAND_PREFIX, OCSF_PREFIX] {
seed_history(directory.path(), prefix, true);
}
let legacy = directory.path().join("openshell.1999-12-31.log");
std::fs::write(&legacy, b"legacy shorthand").unwrap();
let mut shorthand =
appender(directory.path(), SHORTHAND_PREFIX, Rotation::MINUTELY).unwrap();
let mut jsonl = appender(directory.path(), OCSF_PREFIX, Rotation::MINUTELY).unwrap();
let shorthand_history = history(directory.path(), SHORTHAND_PREFIX);
let jsonl_history = history(directory.path(), OCSF_PREFIX);
let deadline = Instant::now() + Duration::from_secs(65);

loop {
shorthand.write_all(b"shorthand event\n").unwrap();
shorthand.flush().unwrap();
let current = history(directory.path(), SHORTHAND_PREFIX);
if current.keys().ne(shorthand_history.keys()) {
assert_eq!(current.len(), 3);
break;
}
assert!(Instant::now() < deadline, "no rollover within 65 seconds");
std::thread::sleep(Duration::from_millis(100));
}
assert_eq!(history(directory.path(), OCSF_PREFIX), jsonl_history);

let shorthand_history = history(directory.path(), SHORTHAND_PREFIX);
loop {
jsonl.write_all(b"jsonl event\n").unwrap();
jsonl.flush().unwrap();
let current = history(directory.path(), OCSF_PREFIX);
if current.keys().ne(jsonl_history.keys()) {
assert_eq!(current.len(), 3);
break;
}
assert!(Instant::now() < deadline, "no rollover within 65 seconds");
std::thread::sleep(Duration::from_millis(100));
}
assert_eq!(
history(directory.path(), SHORTHAND_PREFIX),
shorthand_history
);
assert_eq!(std::fs::read(&legacy).unwrap(), b"legacy shorthand");
}
}
18 changes: 9 additions & 9 deletions docs/observability/accessing-logs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ Start an independent shell with `sandbox exec` to read log files directly:

```text
openshell sandbox exec --name my-sandbox --tty -- /bin/bash -l
sandbox@my-sandbox:~$ cat /var/log/openshell.2026-04-01.log
sandbox@my-sandbox:~$ cat /var/log/openshell-text.2026-04-01.log
```

Or run a one-off command without an interactive shell:

```shell
openshell sandbox exec --name my-sandbox -- cat /var/log/openshell.2026-04-01.log
openshell sandbox exec --name my-sandbox -- cat /var/log/openshell-text.2026-04-01.log
```

`sandbox connect` attaches to the sandbox's existing canonical main process; it
Expand All @@ -98,25 +98,25 @@ The shorthand format is designed for `grep`. Some useful patterns:

```shell
# All denied connections
grep "DENIED\|BLOCKED" /var/log/openshell.*.log
grep "DENIED\|BLOCKED" /var/log/openshell-text.*.log

# All network events
grep "OCSF NET:" /var/log/openshell.*.log
grep "OCSF NET:" /var/log/openshell-text.*.log

# All L7 enforcement decisions
grep "OCSF HTTP:" /var/log/openshell.*.log
grep "OCSF HTTP:" /var/log/openshell-text.*.log

# Security findings only
grep "OCSF FINDING:" /var/log/openshell.*.log
grep "OCSF FINDING:" /var/log/openshell-text.*.log

# Policy changes
grep "OCSF CONFIG:" /var/log/openshell.*.log
grep "OCSF CONFIG:" /var/log/openshell-text.*.log

# All OCSF events, excluding standard tracing
grep "^.* OCSF " /var/log/openshell.*.log
grep "^.* OCSF " /var/log/openshell-text.*.log

# Events at medium severity or above
grep "\[MED\]\|\[HIGH\]\|\[CRIT\]\|\[FATAL\]" /var/log/openshell.*.log
grep "\[MED\]\|\[HIGH\]\|\[CRIT\]\|\[FATAL\]" /var/log/openshell-text.*.log
```

## Next Steps
Expand Down
10 changes: 8 additions & 2 deletions docs/observability/logging.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,16 @@ Inside the sandbox, logs are written to `/var/log/`:

| File | Format | Rotation |
|---|---|---|
| `openshell.YYYY-MM-DD.log` | Shorthand + standard tracing | Daily, 3 files max |
| `openshell-text.YYYY-MM-DD.log` | Shorthand + standard tracing | Daily, 3 files max |
| `openshell-ocsf.YYYY-MM-DD.log` | OCSF JSONL when enabled | Daily, 3 files max |

Both files rotate daily and retain the 3 most recent files to bound disk usage.
Each format rotates daily and retains up to 3 files independently.

Update shorthand collectors from `/var/log/openshell.*.log` to
`/var/log/openshell-text.*.log`. Existing `openshell.YYYY-MM-DD.log` files remain
readable, including through `/v1/denials`, but the new appender does not prune
them. Collect and remove those legacy files as needed. The OCSF JSONL collection
path is unchanged.

## Next Steps

Expand Down
2 changes: 1 addition & 1 deletion docs/observability/ocsf-json-export.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ cat /var/log/openshell-ocsf.2026-04-01.log | \

Sandbox events identify the sandbox environment with `device.type_id: 99` (Other) and `device.type: "Sandbox"`. The operating system is reported separately in `device.os.name`; the device type does not classify the underlying host or compute backend.

The shorthand format in `openshell.YYYY-MM-DD.log` and the JSON format in `openshell-ocsf.YYYY-MM-DD.log` are derived from the same OCSF events. The shorthand is a human-readable projection; the JSON is the full structured record when no schema downgrade is configured. When `ocsf_schema_version` is set, the JSON export is a lossy projection of the internal event model. Both formats are generated at the same time from the same event data.
The shorthand format in `openshell-text.YYYY-MM-DD.log` and the JSON format in `openshell-ocsf.YYYY-MM-DD.log` are derived from the same OCSF events. The shorthand is a human-readable projection; the JSON is the full structured record when no schema downgrade is configured. When `ocsf_schema_version` is set, the JSON export is a lossy projection of the internal event model. Both formats are generated at the same time from the same event data.

The shorthand log is always active. The JSON export is opt-in through `ocsf_json_enabled`.

Expand Down
Loading