Repositories / agent-snapshot.git

agent-snapshot.git

Clone (read-only): git clone http://git.guha-anderson.com/git/agent-snapshot.git

Branch

Implement ptrace snapshot capture

Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-05-02 07:05:19 -0400
Commit
10b87636e186559bf76aa4bd666bd78d87333db3
src/main.cpp
index 1472a70..fe3d2c0 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -171,7 +171,7 @@ bool owned_by_other_and_not_writable(const fs::path& path) {
   struct stat st {};
   if (lstat(path.c_str(), &st) != 0) return false;
   if (st.st_uid == tracer_uid) return false;
-  if (st.st_mode & (S_IWUSR | S_IWGRP | S_IWOTH)) return false;
+  if (access(path.c_str(), W_OK) == 0) return false;
   return true;
 }
 
@@ -369,6 +369,7 @@ void handle_syscall_entry(pid_t pid, ProcState& proc, const user_regs_struct& re
     case SYS_open:
       p.path_a = resolve_path(proc, AT_FDCWD, read_tracee_string(pid, p.args[0])).string();
       p.flags = static_cast<int>(p.args[1]);
+      if (is_write_open(p.flags)) record_observation(p.path_a, "write");
       break;
     case SYS_openat:
 #ifdef SYS_openat2
@@ -377,10 +378,12 @@ void handle_syscall_entry(pid_t pid, ProcState& proc, const user_regs_struct& re
       p.dirfd = static_cast<int>(p.args[0]);
       p.path_a = resolve_path(proc, p.dirfd, read_tracee_string(pid, p.args[1])).string();
       p.flags = static_cast<int>(p.args[2]);
+      if (is_write_open(p.flags)) record_observation(p.path_a, "write");
       break;
     case SYS_creat:
       p.path_a = resolve_path(proc, AT_FDCWD, read_tracee_string(pid, p.args[0])).string();
       p.flags = O_CREAT | O_WRONLY | O_TRUNC;
+      record_observation(p.path_a, "write");
       break;
     case SYS_stat:
     case SYS_lstat:
@@ -400,11 +403,13 @@ void handle_syscall_entry(pid_t pid, ProcState& proc, const user_regs_struct& re
     case SYS_unlink:
     case SYS_rmdir:
       p.path_a = resolve_path(proc, AT_FDCWD, read_tracee_string(pid, p.args[0])).string();
+      record_observation(p.path_a, "delete");
       break;
     case SYS_unlinkat:
     case SYS_mkdirat:
       p.dirfd = static_cast<int>(p.args[0]);
       p.path_a = resolve_path(proc, p.dirfd, read_tracee_string(pid, p.args[1])).string();
+      if (p.nr == SYS_unlinkat) record_observation(p.path_a, "delete");
       break;
     case SYS_mkdir:
     case SYS_chdir:
@@ -414,6 +419,8 @@ void handle_syscall_entry(pid_t pid, ProcState& proc, const user_regs_struct& re
     case SYS_rename:
       p.path_a = resolve_path(proc, AT_FDCWD, read_tracee_string(pid, p.args[0])).string();
       p.path_b = resolve_path(proc, AT_FDCWD, read_tracee_string(pid, p.args[1])).string();
+      record_observation(p.path_a, "delete");
+      record_observation(p.path_b, "write");
       break;
     case SYS_renameat:
 #ifdef SYS_renameat2
@@ -421,6 +428,8 @@ void handle_syscall_entry(pid_t pid, ProcState& proc, const user_regs_struct& re
 #endif
       p.path_a = resolve_path(proc, static_cast<int>(p.args[0]), read_tracee_string(pid, p.args[1])).string();
       p.path_b = resolve_path(proc, static_cast<int>(p.args[2]), read_tracee_string(pid, p.args[3])).string();
+      record_observation(p.path_a, "delete");
+      record_observation(p.path_b, "write");
       break;
     case SYS_getdents:
     case SYS_getdents64:
@@ -656,8 +665,15 @@ void restore_snapshot(const fs::path& dir) {
     if (!after.contains("blob")) continue;
 
     fs::create_directories(path.parent_path());
-    fs::copy_file(dir / "blobs" / after.at("blob").get<std::string>(), path,
-                  fs::copy_options::overwrite_existing);
+    const std::string expected_blob = after.at("blob").get<std::string>();
+    if (!(fs::exists(path) && fs::is_regular_file(path) &&
+          fnv1a_file_digest(path) == expected_blob)) {
+      fs::path tmp = path;
+      tmp += ".agent-snapshot.tmp";
+      fs::copy_file(dir / "blobs" / expected_blob, tmp,
+                    fs::copy_options::overwrite_existing);
+      fs::rename(tmp, path);
+    }
     if (after.contains("mode")) {
       fs::permissions(path, static_cast<fs::perms>(after.at("mode").get<unsigned>() & 07777),
                       fs::perm_options::replace);
tests/test_agent_snapshot.py
index 2c8e345..c8a32f8 100644
--- a/tests/test_agent_snapshot.py
+++ b/tests/test_agent_snapshot.py
@@ -11,6 +11,7 @@ ROOT = Path(__file__).resolve().parents[1]
 BUILD = ROOT / "build" / "pytest"
 BIN = BUILD / "agent-snapshot"
 TESTDATA = ROOT / "testdata"
+PYTHON = "/usr/bin/python3"
 
 
 def run(cmd, **kwargs):
@@ -56,7 +57,7 @@ def capture(tmp_path: Path, *command: str) -> Snapshot:
 
 
 def test_clean_git_tracked_read_records_repo_without_blob(tmp_path):
-    snap = capture(tmp_path, "python3", "test_programs/read_clean.py")
+    snap = capture(tmp_path, PYTHON, "test_programs/read_clean.py")
     clean = snap.file(TESTDATA / "clean.txt")
 
     assert "read" in clean["operations"]
@@ -72,7 +73,7 @@ def test_dirty_untracked_created_and_deleted_files_are_captured(tmp_path):
     (TESTDATA / "untracked_runtime.txt").write_text("untracked input\n")
     (TESTDATA / "deleted_by_program.txt").write_text("delete me\n")
 
-    snap = capture(tmp_path, "python3", "test_programs/dirty_untracked_write.py")
+    snap = capture(tmp_path, PYTHON, "test_programs/dirty_untracked_write.py")
 
     dirty = snap.file(TESTDATA / "dirty.txt")
     assert dirty["git"]["tracked"] is True
@@ -95,7 +96,7 @@ def test_dirty_untracked_created_and_deleted_files_are_captured(tmp_path):
 
 
 def test_fork_usr_and_directory_traversal(tmp_path):
-    snap = capture(tmp_path, "python3", "test_programs/fork_and_usr.py")
+    snap = capture(tmp_path, PYTHON, "test_programs/fork_and_usr.py")
 
     child = snap.file(TESTDATA / "child_output.txt")
     assert "write" in child["operations"]
@@ -112,8 +113,9 @@ def test_fork_usr_and_directory_traversal(tmp_path):
 
 def test_restore_applies_final_state(tmp_path):
     (TESTDATA / "dirty.txt").write_text("changed before capture\n")
+    (TESTDATA / "untracked_runtime.txt").write_text("untracked input\n")
     (TESTDATA / "deleted_by_program.txt").write_text("delete me\n")
-    snap = capture(tmp_path, "python3", "test_programs/dirty_untracked_write.py")
+    snap = capture(tmp_path, PYTHON, "test_programs/dirty_untracked_write.py")
 
     shutil.rmtree(TESTDATA)
     TESTDATA.mkdir()
uv.lock
new file mode 100644
index 0000000..5992c47
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,79 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "agent-snapshot"
+version = "0.1.0"
+source = { virtual = "." }
+
+[package.dev-dependencies]
+dev = [
+    { name = "pytest" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.0" }]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]