From d909b07301f72f49357f73bfe80e8e8d8fdeb0c7 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 10 Feb 2026 08:36:22 -0700 Subject: [PATCH] Step 231: Add session anonymizer --- PROGRESS.md | 1 + editor/CMakeLists.txt | 3 + editor/src/SessionAnonymizer.h | 295 +++++++++++++++++++++++++++++++++ editor/tests/step231_test.cpp | 46 +++++ sprint7_plan.md | 2 +- 5 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 editor/src/SessionAnonymizer.h create mode 100644 editor/tests/step231_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index 3c81180..eeeb39d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -750,3 +750,4 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi | 2026-02-10 | Codex | Step 228: Prompt engineering templates (5 prompt files with system/user templates, expected tools, success criteria). 1/1 tests pass. | | 2026-02-10 | Codex | Step 229: Tool definition tests (schema alignment, prompt coverage, examples validation, prompt variable checks). 1/1 tests pass. | | 2026-02-10 | Codex | Step 230: Enhanced session recorder (metadata, event capture, ms timestamps, UI toggle, auto-record setting). 1/1 tests pass. | +| 2026-02-10 | Codex | Step 231: Session anonymizer (paths, secrets, identifier anonymization, comment/string stripping, levels, deterministic seed). 1/1 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 20056fe..1a50d20 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1175,6 +1175,9 @@ target_link_libraries(step229_test PRIVATE nlohmann_json::nlohmann_json) add_executable(step230_test tests/step230_test.cpp) target_include_directories(step230_test PRIVATE src) target_link_libraries(step230_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step231_test tests/step231_test.cpp) +target_include_directories(step231_test PRIVATE src) +target_link_libraries(step231_test PRIVATE nlohmann_json::nlohmann_json) add_executable(step213_test tests/step213_test.cpp) target_include_directories(step213_test PRIVATE src) target_link_libraries(step213_test PRIVATE nlohmann_json::nlohmann_json) diff --git a/editor/src/SessionAnonymizer.h b/editor/src/SessionAnonymizer.h new file mode 100644 index 0000000..b411584 --- /dev/null +++ b/editor/src/SessionAnonymizer.h @@ -0,0 +1,295 @@ +#pragma once +// Step 231: Session anonymizer + +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +class SessionAnonymizer { +public: + enum class Level { + Light, + Medium, + Full + }; + + void setLevel(Level level) { level_ = level; } + void setSeed(uint64_t seed) { seed_ = seed; } + + json anonymize(const json& input) { + reset(); + return anonymizeValue(input, ""); + } + +private: + Level level_ = Level::Medium; + uint64_t seed_ = 0xC0FFEEULL; + std::unordered_map pathMap_; + std::unordered_map identMap_; + std::unordered_map genericMap_; + + void reset() { + pathMap_.clear(); + identMap_.clear(); + genericMap_.clear(); + } + + static bool isIdentifierStart(char c) { + return std::isalpha(static_cast(c)) || c == '_'; + } + + static bool isIdentifierChar(char c) { + return std::isalnum(static_cast(c)) || c == '_'; + } + + static std::unordered_set keywordSet() { + return { + "if", "else", "for", "while", "return", "class", "struct", "def", "fn", + "let", "var", "const", "int", "float", "double", "bool", "string", + "true", "false", "null", "none", "self", "this", "public", "private", + "protected", "static", "void", "new", "delete", "import", "from", "as" + }; + } + + static bool isLikelyPath(const std::string& s) { + if (s.find("\\\\") != std::string::npos) return true; + if (s.find("/") != std::string::npos) { + if (s.find(".") != std::string::npos) return true; + } + if (s.size() > 2 && std::isalpha(static_cast(s[0])) && s[1] == ':') return true; + return false; + } + + static bool keyLooksSensitive(const std::string& key) { + std::string lower; + lower.reserve(key.size()); + for (char c : key) lower.push_back((char)std::tolower((unsigned char)c)); + return lower.find("token") != std::string::npos || + lower.find("apikey") != std::string::npos || + lower.find("api_key") != std::string::npos || + lower.find("secret") != std::string::npos || + lower.find("password") != std::string::npos || + lower.find("key") != std::string::npos; + } + + static bool keyLooksLikePath(const std::string& key) { + std::string lower; + lower.reserve(key.size()); + for (char c : key) lower.push_back((char)std::tolower((unsigned char)c)); + return lower.find("path") != std::string::npos || lower.find("file") != std::string::npos; + } + + static bool keyIsCodePayload(const std::string& key) { + return key == "source" || key == "content" || key == "spec" || key == "code"; + } + + static bool preserveValueForKey(const std::string& key) { + return key == "method" || key == "jsonrpc" || key == "type" || + key == "annotationType" || key == "strategy" || + key == "sourceLanguage" || key == "targetLanguage" || + key == "language" || key == "role"; + } + + static std::string fileExtension(const std::string& path) { + auto pos = path.find_last_of('.'); + if (pos == std::string::npos) return ""; + auto slash = path.find_last_of("/\\"); + if (slash != std::string::npos && pos < slash) return ""; + return path.substr(pos); + } + + uint64_t fnv1a(const std::string& s) const { + uint64_t hash = 1469598103934665603ULL ^ seed_; + for (unsigned char c : s) { + hash ^= c; + hash *= 1099511628211ULL; + } + return hash; + } + + std::string stableToken(const std::string& prefix, const std::string& value) { + uint64_t h = fnv1a(value); + return prefix + std::to_string(static_cast(h % 1000003ULL)); + } + + std::string anonymizePath(const std::string& path) { + auto it = pathMap_.find(path); + if (it != pathMap_.end()) return it->second; + std::string ext = fileExtension(path); + std::string token = stableToken("file_", path); + std::string mapped = "/project/src/" + token + ext; + pathMap_[path] = mapped; + return mapped; + } + + std::string anonymizeIdentifier(const std::string& ident) { + auto it = identMap_.find(ident); + if (it != identMap_.end()) return it->second; + std::string mapped = stableToken("sym_", ident); + identMap_[ident] = mapped; + return mapped; + } + + std::string anonymizeGeneric(const std::string& value) { + auto it = genericMap_.find(value); + if (it != genericMap_.end()) return it->second; + std::string mapped = stableToken("val_", value); + genericMap_[value] = mapped; + return mapped; + } + + std::string stripCommentsAndStrings(const std::string& code) { + std::string out; + out.reserve(code.size()); + bool inString = false; + char stringDelim = 0; + bool inLineComment = false; + bool inBlockComment = false; + for (size_t i = 0; i < code.size(); ++i) { + char c = code[i]; + char next = (i + 1 < code.size()) ? code[i + 1] : '\0'; + + if (inLineComment) { + if (c == '\n') { + inLineComment = false; + out.push_back(c); + } + continue; + } + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + ++i; + } + if (c == '\n') out.push_back(c); + continue; + } + if (inString) { + if (c == '\\' && next != '\0') { + ++i; + continue; + } + if (c == stringDelim) { + inString = false; + out.push_back(c); + } + continue; + } + + if (c == '/' && next == '/') { + inLineComment = true; + ++i; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + ++i; + continue; + } + if (c == '#') { + inLineComment = true; + continue; + } + if (c == '\"' || c == '\'' || c == '`') { + inString = true; + stringDelim = c; + out.push_back(c); + out.append("STR"); + continue; + } + out.push_back(c); + } + return out; + } + + std::string anonymizeIdentifiersInCode(const std::string& code) { + std::string out; + out.reserve(code.size()); + auto keywords = keywordSet(); + for (size_t i = 0; i < code.size();) { + char c = code[i]; + if (isIdentifierStart(c)) { + size_t start = i; + ++i; + while (i < code.size() && isIdentifierChar(code[i])) ++i; + std::string token = code.substr(start, i - start); + if (keywords.count(token)) { + out.append(token); + } else { + out.append(anonymizeIdentifier(token)); + } + continue; + } + out.push_back(c); + ++i; + } + return out; + } + + std::string anonymizeStringValue(const std::string& value, const std::string& key) { + if (!key.empty() && keyLooksLikePath(key)) { + return anonymizePath(value); + } + if (!key.empty() && keyLooksSensitive(key)) { + return ""; + } + if (isLikelyPath(value)) { + return anonymizePath(value); + } + if (keyIsCodePayload(key)) { + std::string stripped = stripCommentsAndStrings(value); + if (level_ != Level::Light) { + stripped = anonymizeIdentifiersInCode(stripped); + } + return stripped; + } + if (level_ == Level::Light) { + return value; + } + if (level_ >= Level::Medium && isIdentifierStart(value[0])) { + bool allIdent = true; + for (char c : value) { + if (!isIdentifierChar(c)) { + allIdent = false; + break; + } + } + if (allIdent && !keywordSet().count(value)) { + return anonymizeIdentifier(value); + } + } + if (level_ == Level::Full && !preserveValueForKey(key)) { + return anonymizeGeneric(value); + } + return value; + } + + json anonymizeValue(const json& value, const std::string& key) { + if (value.is_object()) { + json out = json::object(); + for (auto it = value.begin(); it != value.end(); ++it) { + out[it.key()] = anonymizeValue(it.value(), it.key()); + } + return out; + } + if (value.is_array()) { + json out = json::array(); + for (const auto& item : value) { + out.push_back(anonymizeValue(item, key)); + } + return out; + } + if (value.is_string()) { + const std::string s = value.get(); + if (s.empty()) return value; + return anonymizeStringValue(s, key); + } + return value; + } +}; diff --git a/editor/tests/step231_test.cpp b/editor/tests/step231_test.cpp new file mode 100644 index 0000000..9f8b8d0 --- /dev/null +++ b/editor/tests/step231_test.cpp @@ -0,0 +1,46 @@ +// Step 231: Session anonymizer. +#include "SessionAnonymizer.h" +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + SessionAnonymizer anon; + anon.setSeed(123); + anon.setLevel(SessionAnonymizer::Level::Medium); + + json input = { + {"path", "C:/Users/Bob/project/main.py"}, + {"source", "def foo(x):\n # comment\n return bar(x)\n"}, + {"apiKey", "SECRET-123"} + }; + + json out1 = anon.anonymize(input); + json out2 = anon.anonymize(input); + + std::string path = out1.value("path", ""); + std::string src = out1.value("source", ""); + + expect(path.find("/project/src/") == 0, "path anonymized", passed, failed); + expect(path.find(".py") != std::string::npos, "path extension preserved", passed, failed); + expect(src.find("comment") == std::string::npos, "comments stripped", passed, failed); + expect(src.find("foo") == std::string::npos, "identifiers anonymized", passed, failed); + expect(src.find("bar") == std::string::npos, "identifiers anonymized 2", passed, failed); + expect(out1.value("apiKey", "") == "", "secrets redacted", passed, failed); + expect(out1.dump() == out2.dump(), "deterministic output", passed, failed); + + std::cout << "\n=== Step 231 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint7_plan.md b/sprint7_plan.md index 519357c..1a43159 100644 --- a/sprint7_plan.md +++ b/sprint7_plan.md @@ -411,7 +411,7 @@ Capture real editing sessions, anonymize them, and export as training data. - Auto-recording mode: always capture (configurable in settings) *Modifies:* `WorkflowRecorder.h`, `panels/StatusBarPanel.h` -- [ ] **Step 231: Session anonymizer** +- [x] **Step 231: Session anonymizer** Strip PII and sensitive data from recorded sessions: - Replace file paths with generic paths (`/project/src/module.py`) - Replace variable/function names with synthetic names (preserve structure)