From 058429fbf858f8bd1681e58dff695dd512934b80 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 11:18:26 -0700 Subject: [PATCH 1/6] feat: add Rust probe template Add the Rust (file ingest) probe template: an owned AgentValue enum with From impls and an adbg! json-style macro, hand-rolled key normalization matching the shared redaction contract (Rust std has no regex), a backslash-free JSON serializer, verbatim redaction policy, 65536-byte cap, depth-64 cap, epoch-ms timestamp, 0600 append via OpenOptions, and total failure suppression. Registered as "rust" (alias "rs"), file ingest only. Cycles are unrepresentable in an owned value tree, so the live cycle fixture exercises the depth cap instead. Docs matrices and contract/e2e suites extended; live e2e compiles single-file rustc with no cargo/serde. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 5 +- README.md | 1 + skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 2 +- specs/building-a-debug-mode-agent.md | 3 +- src/probes/render.ts | 9 +- src/probes/rust.ts | 292 ++++++++++++++++++++++ tests/contract/template-renderers.test.ts | 5 +- tests/e2e/languages/live-probes.test.ts | 24 ++ tests/fixtures/languages/rust-file.rs | 6 + 10 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 src/probes/rust.ts create mode 100644 tests/fixtures/languages/rust-file.rs diff --git a/DESIGN.md b/DESIGN.md index 4f7a0e4..83cb82a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,9 +115,10 @@ The first supported combinations are: - PowerShell + file - C# + file - Swift + file +- Rust + file -Every advertised combination must pass a live end-to-end test with its real runtime. Rust, Java, -Kotlin, C, C++, and shell are not advertised until a safe serializer contract is defined. +Every advertised combination must pass a live end-to-end test with its real runtime. Java, Kotlin, +C, C++, and shell are not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/README.md b/README.md index be06eec..b489ca8 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ runtime: | PowerShell | file | | C# | file | | Swift | file | +| Rust | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index b3da0a4..ee3a1a5 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -31,6 +31,7 @@ schema** for one language and transport. Templates never take `--session`. | PowerShell | `powershell` (`pwsh`) | `file` | | C# | `csharp` (`cs`, `c#`) | `file` | | Swift | `swift` | `file` | +| Rust | `rust` (`rs`) | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index 868768a..72dde64 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -114,7 +114,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | TypeScript | http | PHP | file | | Python | file | PowerShell | file | | Go | file | C# | file | -| Swift | file | | | +| Swift | file | Rust | file | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index 496ea74..a6b430f 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -146,6 +146,7 @@ advertised, end-to-end-tested combinations are: - PowerShell + file - C# + file - Swift + file +- Rust + file Other languages are not advertised until a safe serializer contract is defined. @@ -266,7 +267,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/render.ts b/src/probes/render.ts index 549cf95..bd5a748 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -5,6 +5,7 @@ import { renderPhpTemplate } from "./php"; import { renderPowerShellTemplate } from "./powershell"; import { renderPythonTemplate } from "./python"; import { renderRubyTemplate } from "./ruby"; +import { renderRustTemplate } from "./rust"; import { renderSwiftTemplate } from "./swift"; import { renderTypeScriptTemplate } from "./typescript"; @@ -17,7 +18,8 @@ export type TemplateLanguage = | "php" | "powershell" | "csharp" - | "swift"; + | "swift" + | "rust"; export type IngestMethod = "http" | "file"; @@ -77,6 +79,9 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { return "csharp"; case "swift": return "swift"; + case "rust": + case "rs": + return "rust"; default: return undefined; } @@ -109,6 +114,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderCSharpTemplate(); case "swift:file": return renderSwiftTemplate(); + case "rust:file": + return renderRustTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/src/probes/rust.ts b/src/probes/rust.ts new file mode 100644 index 0000000..3813750 --- /dev/null +++ b/src/probes/rust.ts @@ -0,0 +1,292 @@ +import type { ProbeTemplates } from "./render"; + +export function renderRustTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "#[derive(Clone)]", + "enum AgentValue {", + " Null,", + " Bool(bool),", + " Int(i64),", + " Float(f64),", + " Str(String),", + " Array(Vec),", + " Object(Vec<(String, AgentValue)>),", + "}", + "", + "impl From for AgentValue { fn from(value: bool) -> Self { AgentValue::Bool(value) } }", + "impl From for AgentValue { fn from(value: i64) -> Self { AgentValue::Int(value) } }", + "impl From for AgentValue { fn from(value: f64) -> Self { AgentValue::Float(value) } }", + "impl From<&str> for AgentValue { fn from(value: &str) -> Self { AgentValue::Str(value.to_string()) } }", + "impl From for AgentValue { fn from(value: String) -> Self { AgentValue::Str(value) } }", + "", + "macro_rules! adbg {", + " ({ $($key:tt : $value:tt),* $(,)? }) => {", + " AgentValue::Object(vec![ $( ($key.to_string(), adbg!($value)) ),* ])", + " };", + " ([ $($value:tt),* $(,)? ]) => {", + " AgentValue::Array(vec![ $( adbg!($value) ),* ])", + " };", + " ($other:expr) => {", + " AgentValue::from($other)", + " };", + "}", + "", + "const AGENT_DEBUG_SECRET_KEYS: [&str; 12] = [", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials",', + "];", + "", + "const AGENT_DEBUG_SECRET_SUFFIXES: [&str; 11] = [", + ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', + ' "client_secret", "access_token", "refresh_token", "id_token",', + ' "auth_token", "bearer_token",', + "];", + "", + "fn __agent_debug_split_acronym(input: &str) -> String {", + " let chars: Vec = input.chars().collect();", + " let mut result = String::new();", + " let mut i = 0;", + " while i < chars.len() {", + " if chars[i].is_ascii_uppercase() {", + " let mut j = i;", + " while j < chars.len() && chars[j].is_ascii_uppercase() {", + " j += 1;", + " }", + " if j - i >= 2 && j < chars.len() && chars[j].is_ascii_lowercase() {", + " for k in i..(j - 1) {", + " result.push(chars[k]);", + " }", + " result.push('_');", + " result.push(chars[j - 1]);", + " result.push(chars[j]);", + " i = j + 1;", + " continue;", + " }", + " for k in i..j {", + " result.push(chars[k]);", + " }", + " i = j;", + " continue;", + " }", + " result.push(chars[i]);", + " i += 1;", + " }", + " result", + "}", + "", + "fn __agent_debug_split_camel(input: &str) -> String {", + " let chars: Vec = input.chars().collect();", + " let mut result = String::new();", + " let mut i = 0;", + " while i < chars.len() {", + " result.push(chars[i]);", + " let boundary = chars[i].is_ascii_lowercase() || chars[i].is_ascii_digit();", + " if boundary && i + 1 < chars.len() && chars[i + 1].is_ascii_uppercase() {", + " result.push('_');", + " }", + " i += 1;", + " }", + " result", + "}", + "", + "fn __agent_debug_normalize_key(key: &str) -> String {", + " let split = __agent_debug_split_camel(&__agent_debug_split_acronym(key));", + " let mut normalized = String::new();", + " let mut pending_underscore = false;", + " for c in split.chars() {", + " if c.is_ascii_alphanumeric() {", + " if pending_underscore && !normalized.is_empty() {", + " normalized.push('_');", + " }", + " pending_underscore = false;", + " for lower in c.to_lowercase() {", + " normalized.push(lower);", + " }", + " } else {", + " pending_underscore = true;", + " }", + " }", + " normalized", + "}", + "", + "fn __agent_debug_is_secret_key(key: &str) -> bool {", + " let normalized = __agent_debug_normalize_key(key);", + " for exact in AGENT_DEBUG_SECRET_KEYS.iter() {", + " if normalized == *exact {", + " return true;", + " }", + " }", + " let bytes = normalized.as_bytes();", + " for suffix in AGENT_DEBUG_SECRET_SUFFIXES.iter() {", + " if normalized.ends_with(suffix) {", + " let boundary = normalized.len() - suffix.len();", + " if boundary == 0 || bytes[boundary - 1] == b'_' {", + " return true;", + " }", + " }", + " }", + " false", + "}", + "", + "fn __agent_debug_write_string(out: &mut String, text: &str) {", + " let backslash = char::from(92u8);", + " let quote = char::from(34u8);", + " out.push(quote);", + " for c in text.chars() {", + " let code = c as u32;", + " if c == quote {", + " out.push(backslash);", + " out.push(quote);", + " } else if c == backslash {", + " out.push(backslash);", + " out.push(backslash);", + " } else if code == 8 {", + " out.push(backslash);", + " out.push(char::from(98u8));", + " } else if code == 9 {", + " out.push(backslash);", + " out.push(char::from(116u8));", + " } else if code == 10 {", + " out.push(backslash);", + " out.push(char::from(110u8));", + " } else if code == 12 {", + " out.push(backslash);", + " out.push(char::from(102u8));", + " } else if code == 13 {", + " out.push(backslash);", + " out.push(char::from(114u8));", + " } else if code < 32 {", + ' let digits = "0123456789abcdef".as_bytes();', + " out.push(backslash);", + " out.push(char::from(117u8));", + " out.push(char::from(48u8));", + " out.push(char::from(48u8));", + " out.push(char::from(digits[((code >> 4) & 15) as usize]));", + " out.push(char::from(digits[(code & 15) as usize]));", + " } else {", + " out.push(c);", + " }", + " }", + " out.push(quote);", + "}", + "", + "fn __agent_debug_write_value(out: &mut String, value: &AgentValue, depth: usize) -> bool {", + " if depth > 64 {", + " return false;", + " }", + " match value {", + ' AgentValue::Null => out.push_str("null"),', + ' AgentValue::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),', + " AgentValue::Int(number) => out.push_str(&number.to_string()),", + " AgentValue::Float(number) => {", + " if !number.is_finite() {", + " return false;", + " }", + " out.push_str(&number.to_string());", + " }", + " AgentValue::Str(text) => __agent_debug_write_string(out, text),", + " AgentValue::Array(items) => {", + " out.push('[');", + " let mut index = 0;", + " for item in items.iter() {", + " if index > 0 {", + " out.push(',');", + " }", + " index += 1;", + " if !__agent_debug_write_value(out, item, depth + 1) {", + " return false;", + " }", + " }", + " out.push(']');", + " }", + " AgentValue::Object(entries) => {", + " out.push('{');", + " let mut index = 0;", + " for entry in entries.iter() {", + " if index > 0 {", + " out.push(',');", + " }", + " index += 1;", + " __agent_debug_write_string(out, &entry.0);", + " out.push(':');", + " if __agent_debug_is_secret_key(&entry.0) {", + ' __agent_debug_write_string(out, "[REDACTED]");', + " } else if !__agent_debug_write_value(out, &entry.1, depth + 1) {", + " return false;", + " }", + " }", + " out.push('}');", + " }", + " }", + " true", + "}", + "", + "fn __agent_debug_timestamp_ms() -> i64 {", + " match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {", + " Ok(elapsed) => elapsed.as_millis() as i64,", + " Err(_) => 0,", + " }", + "}", + "", + "fn __agentDebugEmit(hypothesis_id: &str, location: &str, message: &str, data: AgentValue) {", + " let mut out = String::new();", + " out.push('{');", + ' __agent_debug_write_string(&mut out, "hypothesisId");', + " out.push(':');", + " __agent_debug_write_string(&mut out, hypothesis_id);", + " out.push(',');", + ' __agent_debug_write_string(&mut out, "location");', + " out.push(':');", + " __agent_debug_write_string(&mut out, location);", + " out.push(',');", + ' __agent_debug_write_string(&mut out, "message");', + " out.push(':');", + " __agent_debug_write_string(&mut out, message);", + " out.push(',');", + ' __agent_debug_write_string(&mut out, "data");', + " out.push(':');", + " if !__agent_debug_write_value(&mut out, &data, 0) {", + " return;", + " }", + " out.push(',');", + ' __agent_debug_write_string(&mut out, "timestamp");', + " out.push(':');", + " out.push_str(&__agent_debug_timestamp_ms().to_string());", + " out.push('}');", + " out.push(char::from(10u8));", + " if out.len() > 65536 {", + " return;", + " }", + " let mut options = std::fs::OpenOptions::new();", + " options.create(true).append(true);", + " #[cfg(unix)]", + " {", + " use std::os::unix::fs::OpenOptionsExt;", + " options.mode(0o600);", + " }", + ' if let Ok(mut file) = options.open("__APPEND_PATH__") {', + " use std::io::Write;", + " let _ = file.write_all(out.as_bytes());", + " }", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "rust", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible Rust AgentValue that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 0c7192f..f09ee12 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -19,6 +19,7 @@ const supported = [ ["powershell", "file"], ["csharp", "file"], ["swift", "file"], + ["rust", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -109,6 +110,8 @@ describe("session-independent template renderers", () => { expect(renderTemplate("C#", "file").language).toBe("csharp"); expect(renderTemplate("cs", "file").language).toBe("csharp"); expect(renderTemplate("SWIFT", "FILE").language).toBe("swift"); + expect(renderTemplate("Rust", "FILE").language).toBe("rust"); + expect(renderTemplate("rs", "file").language).toBe("rust"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -122,7 +125,7 @@ describe("session-independent template renderers", () => { ["powershell", "http"], ["csharp", "http"], ["swift", "http"], - ["rust", "file"], + ["rust", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index 49c3aa4..a31e754 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -206,6 +206,30 @@ const fixtures: Fixture[] = [ sharedData: '["left": __agentShared, "right": __agentShared]', sharedPrelude: 'let __agentShared: [String: Any] = ["APIKey": "source-shared-secret"]', }, + { + callData: + 'adbg!({ "value": 42i64, "designToken": "visible-design-token", "fortuneCookie": "visible-fortune-cookie", "secretSauceName": "visible-secret-sauce", "tokenCount": 7i64, "passwordPolicy": "visible-password-policy", "password": "source-password-secret", "APIKey": "source-api-acronym-secret", "APIToken": "source-api-token-secret", "IDToken": "source-id-token-secret", "OAuthToken": "source-oauth-token-secret", "Client Secret": "source-client-secret", "nested": { "apiKey": "source-api-secret", "items": [ { "refresh-token": "source-refresh-secret" }, { "credentials": "source-credentials-secret" } ] } })', + // Rust's AgentValue is an owned tree, so a reference cycle is unrepresentable. + // The depth-64 cap is the analogous unbounded-structure rejection: a value + // nested past the cap is dropped without emitting, exactly like a cycle would be. + command: (path) => { + const rustc = Bun.which("rustc") ?? ""; + const binary = path.replace(/\.rs$/, process.platform === "win32" ? ".exe" : ".out"); + const script = `"${rustc}" -A warnings "${path}" -o "${binary}" && "${binary}"`; + return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + }, + cycleData: "__agent_cycle", + cyclePrelude: + "let mut __agent_cycle = AgentValue::Int(0); let mut __agent_depth = 0; while __agent_depth < 100 { __agent_cycle = AgentValue::Array(vec![__agent_cycle]); __agent_depth += 1; }", + file: "rust-file.rs", + ingest: "file", + language: "rust", + runtime: Bun.which("rustc"), + sharedData: + 'AgentValue::Object(vec![("left".to_string(), __agent_shared.clone()), ("right".to_string(), __agent_shared)])', + sharedPrelude: + 'let __agent_shared = AgentValue::Object(vec![("APIKey".to_string(), AgentValue::from("source-shared-secret"))]);', + }, ]; async function run(command: string[], env: Record = process.env) { diff --git a/tests/fixtures/languages/rust-file.rs b/tests/fixtures/languages/rust-file.rs new file mode 100644 index 0000000..da5268b --- /dev/null +++ b/tests/fixtures/languages/rust-file.rs @@ -0,0 +1,6 @@ +__HELPER_TEMPLATE__ + +fn main() { + __CALL_TEMPLATE__ + println!("application-completed"); +} From 93428ce8085255b93eb2abcadbc9bd70693e09e2 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 11:30:27 -0700 Subject: [PATCH 2/6] feat: add C++ probe template Add the C++ (file ingest) probe template: an owned AgentValue struct with implicit ctors from bool/long long/double/const char*/std::string plus an nlohmann-style initializer_list constructor for nested object/array literals, hand-rolled key normalization matching the shared redaction contract (no regex, ported 1:1 from the verified Rust normalizer), a backslash-free JSON serializer via ostringstream, verbatim redaction policy, 65536-byte cap, depth-64 cap, chrono epoch-ms timestamp, ofstream append with 0600 on POSIX, and total failure suppression (try/catch(...)). Registered as "cpp" (aliases "c++", "cxx"), file ingest only. Cycles are unrepresentable in an owned value tree, so the live cycle fixture exercises the depth cap instead. Docs matrices and contract/e2e suites extended; live e2e compiles single-file clang++ (g++ fallback), -std=c++17. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 3 +- README.md | 1 + skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 1 + specs/building-a-debug-mode-agent.md | 3 +- src/probes/cpp.ts | 331 ++++++++++++++++++++++ src/probes/render.ts | 10 +- tests/contract/template-renderers.test.ts | 5 + tests/e2e/languages/live-probes.test.ts | 23 ++ tests/fixtures/languages/cpp-file.cpp | 7 + 10 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 src/probes/cpp.ts create mode 100644 tests/fixtures/languages/cpp-file.cpp diff --git a/DESIGN.md b/DESIGN.md index 83cb82a..9e89c1f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -116,9 +116,10 @@ The first supported combinations are: - C# + file - Swift + file - Rust + file +- C++ + file Every advertised combination must pass a live end-to-end test with its real runtime. Java, Kotlin, -C, C++, and shell are not advertised until a safe serializer contract is defined. +C, and shell are not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/README.md b/README.md index b489ca8..613886a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ runtime: | C# | file | | Swift | file | | Rust | file | +| C++ | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index ee3a1a5..aa8560e 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -32,6 +32,7 @@ schema** for one language and transport. Templates never take `--session`. | C# | `csharp` (`cs`, `c#`) | `file` | | Swift | `swift` | `file` | | Rust | `rust` (`rs`) | `file` | +| C++ | `cpp` (`c++`, `cxx`) | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index 72dde64..e64fd4a 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -115,6 +115,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | Python | file | PowerShell | file | | Go | file | C# | file | | Swift | file | Rust | file | +| C++ | file | | | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index a6b430f..2a1d44f 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -147,6 +147,7 @@ advertised, end-to-end-tested combinations are: - C# + file - Swift + file - Rust + file +- C++ + file Other languages are not advertised until a safe serializer contract is defined. @@ -267,7 +268,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift, Rust | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust, C++ | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/cpp.ts b/src/probes/cpp.ts new file mode 100644 index 0000000..8921ad5 --- /dev/null +++ b/src/probes/cpp.ts @@ -0,0 +1,331 @@ +import type { ProbeTemplates } from "./render"; + +export function renderCppTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#ifndef _WIN32", + "#include ", + "#endif", + "", + "enum class AgentKind { Null, Bool, Int, Double, Str, Array, Object };", + "", + "struct AgentValue {", + " AgentKind kind = AgentKind::Null;", + " bool boolValue = false;", + " long long intValue = 0;", + " double doubleValue = 0.0;", + " std::string strValue;", + " std::vector arrayValue;", + " std::vector> objectValue;", + "", + " AgentValue() {}", + " AgentValue(bool v) { kind = AgentKind::Bool; boolValue = v; }", + " AgentValue(long long v) { kind = AgentKind::Int; intValue = v; }", + " AgentValue(double v) { kind = AgentKind::Double; doubleValue = v; }", + " AgentValue(const char* v) { kind = AgentKind::Str; strValue = v; }", + " AgentValue(const std::string& v) { kind = AgentKind::Str; strValue = v; }", + " AgentValue(std::initializer_list items) {", + " bool isObject = items.size() > 0;", + " for (const auto& item : items) {", + " if (item.kind != AgentKind::Array || item.arrayValue.size() != 2 ||", + " item.arrayValue[0].kind != AgentKind::Str) {", + " isObject = false;", + " break;", + " }", + " }", + " if (isObject) {", + " kind = AgentKind::Object;", + " for (const auto& item : items) {", + " objectValue.emplace_back(item.arrayValue[0].strValue, item.arrayValue[1]);", + " }", + " } else {", + " kind = AgentKind::Array;", + " arrayValue = std::vector(items);", + " }", + " }", + "};", + "", + "static const char* const AGENT_DEBUG_SECRET_KEYS[] = {", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials"};', + "", + "static const char* const AGENT_DEBUG_SECRET_SUFFIXES[] = {", + ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', + ' "client_secret", "access_token", "refresh_token", "id_token",', + ' "auth_token", "bearer_token"};', + "", + "static std::string __agent_debug_split_acronym(const std::string& input) {", + " std::string result;", + " size_t n = input.size();", + " size_t i = 0;", + " while (i < n) {", + " unsigned char ci = static_cast(input[i]);", + " if (ci >= 'A' && ci <= 'Z') {", + " size_t j = i;", + " while (j < n) {", + " unsigned char cj = static_cast(input[j]);", + " if (cj >= 'A' && cj <= 'Z') {", + " j += 1;", + " } else {", + " break;", + " }", + " }", + " unsigned char after = (j < n) ? static_cast(input[j]) : 0;", + " if (j - i >= 2 && j < n && after >= 'a' && after <= 'z') {", + " for (size_t k = i; k < j - 1; k += 1) {", + " result.push_back(input[k]);", + " }", + " result.push_back('_');", + " result.push_back(input[j - 1]);", + " result.push_back(input[j]);", + " i = j + 1;", + " continue;", + " }", + " for (size_t k = i; k < j; k += 1) {", + " result.push_back(input[k]);", + " }", + " i = j;", + " continue;", + " }", + " result.push_back(input[i]);", + " i += 1;", + " }", + " return result;", + "}", + "", + "static std::string __agent_debug_split_camel(const std::string& input) {", + " std::string result;", + " size_t n = input.size();", + " for (size_t i = 0; i < n; i += 1) {", + " unsigned char ci = static_cast(input[i]);", + " result.push_back(input[i]);", + " bool boundary = (ci >= 'a' && ci <= 'z') || (ci >= '0' && ci <= '9');", + " if (boundary && i + 1 < n) {", + " unsigned char next = static_cast(input[i + 1]);", + " if (next >= 'A' && next <= 'Z') {", + " result.push_back('_');", + " }", + " }", + " }", + " return result;", + "}", + "", + "static std::string __agent_debug_normalize_key(const std::string& key) {", + " std::string split = __agent_debug_split_camel(__agent_debug_split_acronym(key));", + " std::string normalized;", + " bool pending_underscore = false;", + " for (unsigned char c : split) {", + " bool alnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');", + " if (alnum) {", + " if (pending_underscore && !normalized.empty()) {", + " normalized.push_back('_');", + " }", + " pending_underscore = false;", + " if (c >= 'A' && c <= 'Z') {", + " normalized.push_back(static_cast(c - 'A' + 'a'));", + " } else {", + " normalized.push_back(static_cast(c));", + " }", + " } else {", + " pending_underscore = true;", + " }", + " }", + " return normalized;", + "}", + "", + "static bool __agent_debug_is_secret_key(const std::string& key) {", + " std::string normalized = __agent_debug_normalize_key(key);", + " for (const auto& exact : AGENT_DEBUG_SECRET_KEYS) {", + " if (normalized == exact) {", + " return true;", + " }", + " }", + " for (const auto& suffix : AGENT_DEBUG_SECRET_SUFFIXES) {", + " std::string tail(suffix);", + " if (normalized.size() >= tail.size() &&", + " normalized.compare(normalized.size() - tail.size(), tail.size(), tail) == 0) {", + " size_t boundary = normalized.size() - tail.size();", + " if (boundary == 0 || normalized[boundary - 1] == '_') {", + " return true;", + " }", + " }", + " }", + " return false;", + "}", + "", + "static void __agent_debug_write_string(std::ostringstream& out, const std::string& text) {", + " const char backslash = static_cast(0x5C);", + " const char quote = static_cast(0x22);", + " out << quote;", + " for (unsigned char c : text) {", + " unsigned int code = static_cast(c);", + " if (c == quote) {", + " out << backslash << quote;", + " } else if (c == backslash) {", + " out << backslash << backslash;", + " } else if (code == 8) {", + " out << backslash << 'b';", + " } else if (code == 9) {", + " out << backslash << 't';", + " } else if (code == 10) {", + " out << backslash << 'n';", + " } else if (code == 12) {", + " out << backslash << 'f';", + " } else if (code == 13) {", + " out << backslash << 'r';", + " } else if (code < 32) {", + ' const char* digits = "0123456789abcdef";', + " out << backslash << 'u' << '0' << '0';", + " out << digits[(code >> 4) & 15] << digits[code & 15];", + " } else {", + " out << static_cast(c);", + " }", + " }", + " out << quote;", + "}", + "", + "static bool __agent_debug_write_value(std::ostringstream& out, const AgentValue& value, int depth) {", + " if (depth > 64) {", + " return false;", + " }", + " switch (value.kind) {", + " case AgentKind::Null:", + ' out << "null";', + " break;", + " case AgentKind::Bool:", + ' out << (value.boolValue ? "true" : "false");', + " break;", + " case AgentKind::Int:", + " out << value.intValue;", + " break;", + " case AgentKind::Double: {", + " if (!std::isfinite(value.doubleValue)) {", + " return false;", + " }", + " std::ostringstream number;", + " number << std::setprecision(17) << value.doubleValue;", + " out << number.str();", + " break;", + " }", + " case AgentKind::Str:", + " __agent_debug_write_string(out, value.strValue);", + " break;", + " case AgentKind::Array: {", + " out << '[';", + " int index = 0;", + " for (const auto& item : value.arrayValue) {", + " if (index > 0) {", + " out << ',';", + " }", + " index += 1;", + " if (!__agent_debug_write_value(out, item, depth + 1)) {", + " return false;", + " }", + " }", + " out << ']';", + " break;", + " }", + " case AgentKind::Object: {", + " out << '{';", + " int index = 0;", + " for (const auto& entry : value.objectValue) {", + " if (index > 0) {", + " out << ',';", + " }", + " index += 1;", + " __agent_debug_write_string(out, entry.first);", + " out << ':';", + " if (__agent_debug_is_secret_key(entry.first)) {", + ' __agent_debug_write_string(out, "[REDACTED]");', + " } else if (!__agent_debug_write_value(out, entry.second, depth + 1)) {", + " return false;", + " }", + " }", + " out << '}';", + " break;", + " }", + " }", + " return true;", + "}", + "", + "static long long __agent_debug_timestamp_ms() {", + " return std::chrono::duration_cast(", + " std::chrono::system_clock::now().time_since_epoch())", + " .count();", + "}", + "", + "static void __agentDebugEmit(const std::string& hypothesisId, const std::string& location,", + " const std::string& message, const AgentValue& data) {", + " try {", + " std::ostringstream out;", + " out << '{';", + ' __agent_debug_write_string(out, "hypothesisId");', + " out << ':';", + " __agent_debug_write_string(out, hypothesisId);", + " out << ',';", + ' __agent_debug_write_string(out, "location");', + " out << ':';", + " __agent_debug_write_string(out, location);", + " out << ',';", + ' __agent_debug_write_string(out, "message");', + " out << ':';", + " __agent_debug_write_string(out, message);", + " out << ',';", + ' __agent_debug_write_string(out, "data");', + " out << ':';", + " if (!__agent_debug_write_value(out, data, 0)) {", + " return;", + " }", + " out << ',';", + ' __agent_debug_write_string(out, "timestamp");', + " out << ':';", + " out << __agent_debug_timestamp_ms();", + " out << '}';", + " out << static_cast(0x0A);", + " std::string payload = out.str();", + " if (payload.size() > 65536) {", + " return;", + " }", + ' std::ofstream file("__APPEND_PATH__", std::ios::out | std::ios::app | std::ios::binary);', + " if (!file.is_open()) {", + " return;", + " }", + " file << payload;", + " file.close();", + "#ifndef _WIN32", + ' chmod("__APPEND_PATH__", 0600);', + "#endif", + " } catch (...) {", + " return;", + " }", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "cpp", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible C++ AgentValue that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/src/probes/render.ts b/src/probes/render.ts index bd5a748..f1e488d 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -1,3 +1,4 @@ +import { renderCppTemplate } from "./cpp"; import { renderCSharpTemplate } from "./csharp"; import { renderGoTemplate } from "./go"; import { renderJavaScriptTemplate } from "./javascript"; @@ -19,7 +20,8 @@ export type TemplateLanguage = | "powershell" | "csharp" | "swift" - | "rust"; + | "rust" + | "cpp"; export type IngestMethod = "http" | "file"; @@ -82,6 +84,10 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { case "rust": case "rs": return "rust"; + case "cpp": + case "c++": + case "cxx": + return "cpp"; default: return undefined; } @@ -116,6 +122,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderSwiftTemplate(); case "rust:file": return renderRustTemplate(); + case "cpp:file": + return renderCppTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index f09ee12..4661b64 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -20,6 +20,7 @@ const supported = [ ["csharp", "file"], ["swift", "file"], ["rust", "file"], + ["cpp", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -112,6 +113,9 @@ describe("session-independent template renderers", () => { expect(renderTemplate("SWIFT", "FILE").language).toBe("swift"); expect(renderTemplate("Rust", "FILE").language).toBe("rust"); expect(renderTemplate("rs", "file").language).toBe("rust"); + expect(renderTemplate("CPP", "FILE").language).toBe("cpp"); + expect(renderTemplate("C++", "file").language).toBe("cpp"); + expect(renderTemplate("cxx", "file").language).toBe("cpp"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -126,6 +130,7 @@ describe("session-independent template renderers", () => { ["csharp", "http"], ["swift", "http"], ["rust", "http"], + ["cpp", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index a31e754..da297d6 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -230,6 +230,29 @@ const fixtures: Fixture[] = [ sharedPrelude: 'let __agent_shared = AgentValue::Object(vec![("APIKey".to_string(), AgentValue::from("source-shared-secret"))]);', }, + { + callData: + 'AgentValue{ {"value", 42LL}, {"designToken", "visible-design-token"}, {"fortuneCookie", "visible-fortune-cookie"}, {"secretSauceName", "visible-secret-sauce"}, {"tokenCount", 7LL}, {"passwordPolicy", "visible-password-policy"}, {"password", "source-password-secret"}, {"APIKey", "source-api-acronym-secret"}, {"APIToken", "source-api-token-secret"}, {"IDToken", "source-id-token-secret"}, {"OAuthToken", "source-oauth-token-secret"}, {"Client Secret", "source-client-secret"}, {"nested", AgentValue{ {"apiKey", "source-api-secret"}, {"items", AgentValue{ AgentValue{ {"refresh-token", "source-refresh-secret"} }, AgentValue{ {"credentials", "source-credentials-secret"} } }} }} }', + // C++'s AgentValue is an owned value tree, so a reference cycle is + // unrepresentable. The depth-64 cap is the analogous unbounded-structure + // rejection: a value nested past the cap is dropped without emitting, + // exactly like a cycle would be. + command: (path) => { + const compiler = Bun.which("clang++") ?? Bun.which("g++") ?? ""; + const binary = path.replace(/\.cpp$/, process.platform === "win32" ? ".exe" : ".out"); + const script = `"${compiler}" -std=c++17 "${path}" -o "${binary}" && "${binary}"`; + return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + }, + cycleData: "__agent_cycle", + cyclePrelude: + "AgentValue __agent_cycle = AgentValue(0LL); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { __agent_cycle = AgentValue{ __agent_cycle }; }", + file: "cpp-file.cpp", + ingest: "file", + language: "cpp", + runtime: Bun.which("clang++") ?? Bun.which("g++"), + sharedData: 'AgentValue{ {"left", __agent_shared}, {"right", __agent_shared} }', + sharedPrelude: 'AgentValue __agent_shared = AgentValue{ {"APIKey", "source-shared-secret"} };', + }, ]; async function run(command: string[], env: Record = process.env) { diff --git a/tests/fixtures/languages/cpp-file.cpp b/tests/fixtures/languages/cpp-file.cpp new file mode 100644 index 0000000..b4ffe3e --- /dev/null +++ b/tests/fixtures/languages/cpp-file.cpp @@ -0,0 +1,7 @@ +__HELPER_TEMPLATE__ + +int main() { + __CALL_TEMPLATE__ + std::cout << "application-completed" << std::endl; + return 0; +} From 28a65929436faae1cdb0f99bb35a6f4da1c6513a Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 11:51:52 -0700 Subject: [PATCH 3/6] feat: add C probe template Co-Authored-By: Claude Fable 5 --- DESIGN.md | 3 +- README.md | 1 + skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 2 +- specs/building-a-debug-mode-agent.md | 3 +- src/probes/c.ts | 584 ++++++++++++++++++++++ src/probes/render.ts | 8 +- tests/contract/template-renderers.test.ts | 4 + tests/e2e/languages/live-probes.test.ts | 27 + tests/fixtures/languages/c-file.c | 7 + 10 files changed, 636 insertions(+), 4 deletions(-) create mode 100644 src/probes/c.ts create mode 100644 tests/fixtures/languages/c-file.c diff --git a/DESIGN.md b/DESIGN.md index 9e89c1f..759a87e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -117,9 +117,10 @@ The first supported combinations are: - Swift + file - Rust + file - C++ + file +- C + file Every advertised combination must pass a live end-to-end test with its real runtime. Java, Kotlin, -C, and shell are not advertised until a safe serializer contract is defined. +and shell are not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/README.md b/README.md index 613886a..c2438ca 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ runtime: | Swift | file | | Rust | file | | C++ | file | +| C | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index aa8560e..8f7fd9a 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -33,6 +33,7 @@ schema** for one language and transport. Templates never take `--session`. | Swift | `swift` | `file` | | Rust | `rust` (`rs`) | `file` | | C++ | `cpp` (`c++`, `cxx`) | `file` | +| C | `c` | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index e64fd4a..66f3925 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -115,7 +115,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | Python | file | PowerShell | file | | Go | file | C# | file | | Swift | file | Rust | file | -| C++ | file | | | +| C++ | file | C | file | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index 2a1d44f..db79f8b 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -148,6 +148,7 @@ advertised, end-to-end-tested combinations are: - Swift + file - Rust + file - C++ + file +- C + file Other languages are not advertised until a safe serializer contract is defined. @@ -268,7 +269,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift, Rust, C++ | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust, C++, C | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/c.ts b/src/probes/c.ts new file mode 100644 index 0000000..3199810 --- /dev/null +++ b/src/probes/c.ts @@ -0,0 +1,584 @@ +import type { ProbeTemplates } from "./render"; + +export function renderCTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "#define _POSIX_C_SOURCE 200809L", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#ifndef _WIN32", + "#include ", + "#include ", + "#endif", + "", + "enum {", + " AGENT_KIND_NULL,", + " AGENT_KIND_BOOL,", + " AGENT_KIND_INT,", + " AGENT_KIND_DOUBLE,", + " AGENT_KIND_STR,", + " AGENT_KIND_ARRAY,", + " AGENT_KIND_OBJECT", + "};", + "", + "typedef struct AgentValue AgentValue;", + "struct AgentValue {", + " int kind;", + " int boolValue;", + " long long intValue;", + " double doubleValue;", + " char *strValue;", + " char **keys;", + " AgentValue **items;", + " size_t count;", + "};", + "", + "typedef struct {", + " char *buffer;", + " size_t length;", + " size_t capacity;", + " int overflow;", + "} AgentDebugBuffer;", + "", + "static const char *const AGENT_DEBUG_SECRET_KEYS[] = {", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials"};', + "", + "static const char *const AGENT_DEBUG_SECRET_SUFFIXES[] = {", + ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', + ' "client_secret", "access_token", "refresh_token", "id_token",', + ' "auth_token", "bearer_token"};', + "", + "static AgentValue *__agent_debug_new(int kind) {", + " AgentValue *value = (AgentValue *)malloc(sizeof(AgentValue));", + " if (value == NULL) {", + " return NULL;", + " }", + " value->kind = kind;", + " value->boolValue = 0;", + " value->intValue = 0;", + " value->doubleValue = 0.0;", + " value->strValue = NULL;", + " value->keys = NULL;", + " value->items = NULL;", + " value->count = 0;", + " return value;", + "}", + "", + "static char *__agent_debug_strdup(const char *text) {", + " if (text == NULL) {", + " return NULL;", + " }", + " size_t n = strlen(text);", + " char *copy = (char *)malloc(n + 1);", + " if (copy == NULL) {", + " return NULL;", + " }", + " for (size_t i = 0; i <= n; i += 1) {", + " copy[i] = text[i];", + " }", + " return copy;", + "}", + "", + "static void __agent_debug_free(AgentValue *value) {", + " if (value == NULL) {", + " return;", + " }", + " if (value->strValue != NULL) {", + " free(value->strValue);", + " }", + " if (value->keys != NULL) {", + " for (size_t i = 0; i < value->count; i += 1) {", + " if (value->keys[i] != NULL) {", + " free(value->keys[i]);", + " }", + " }", + " free(value->keys);", + " }", + " if (value->items != NULL) {", + " for (size_t i = 0; i < value->count; i += 1) {", + " __agent_debug_free(value->items[i]);", + " }", + " free(value->items);", + " }", + " free(value);", + "}", + "", + "static AgentValue *adbg_null(void) {", + " return __agent_debug_new(AGENT_KIND_NULL);", + "}", + "", + "static AgentValue *adbg_bool(int flag) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_BOOL);", + " if (value != NULL) {", + " value->boolValue = flag ? 1 : 0;", + " }", + " return value;", + "}", + "", + "static AgentValue *adbg_int(long long number) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_INT);", + " if (value != NULL) {", + " value->intValue = number;", + " }", + " return value;", + "}", + "", + "static AgentValue *adbg_double(double number) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_DOUBLE);", + " if (value != NULL) {", + " value->doubleValue = number;", + " }", + " return value;", + "}", + "", + "static AgentValue *adbg_str(const char *text) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_STR);", + " if (value != NULL) {", + " value->strValue = __agent_debug_strdup(text);", + " }", + " return value;", + "}", + "", + "static AgentValue *adbg_arr(AgentValue *first, ...) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_ARRAY);", + " va_list args;", + " va_start(args, first);", + " AgentValue *current = first;", + " while (current != NULL) {", + " if (value != NULL) {", + " AgentValue **grown = (AgentValue **)realloc(value->items, (value->count + 1) * sizeof(AgentValue *));", + " if (grown != NULL) {", + " value->items = grown;", + " value->items[value->count] = current;", + " value->count += 1;", + " } else {", + " __agent_debug_free(current);", + " }", + " } else {", + " __agent_debug_free(current);", + " }", + " current = va_arg(args, AgentValue *);", + " }", + " va_end(args);", + " return value;", + "}", + "", + "static AgentValue *adbg_obj(const char *first_key, ...) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_OBJECT);", + " va_list args;", + " va_start(args, first_key);", + " const char *key = first_key;", + " while (key != NULL) {", + " AgentValue *child = va_arg(args, AgentValue *);", + " if (value != NULL) {", + " char **grown_keys = (char **)realloc(value->keys, (value->count + 1) * sizeof(char *));", + " if (grown_keys != NULL) {", + " value->keys = grown_keys;", + " }", + " AgentValue **grown_items = (AgentValue **)realloc(value->items, (value->count + 1) * sizeof(AgentValue *));", + " if (grown_items != NULL) {", + " value->items = grown_items;", + " }", + " if (grown_keys != NULL && grown_items != NULL) {", + " value->keys[value->count] = __agent_debug_strdup(key);", + " value->items[value->count] = child;", + " value->count += 1;", + " } else {", + " __agent_debug_free(child);", + " }", + " } else {", + " __agent_debug_free(child);", + " }", + " key = va_arg(args, const char *);", + " }", + " va_end(args);", + " return value;", + "}", + "", + "static char *__agent_debug_split_acronym(const char *input) {", + " size_t n = strlen(input);", + " char *result = (char *)malloc(n * 2 + 1);", + " if (result == NULL) {", + " return NULL;", + " }", + " size_t out = 0;", + " size_t i = 0;", + " while (i < n) {", + " unsigned char ci = (unsigned char)input[i];", + " if (ci >= 'A' && ci <= 'Z') {", + " size_t j = i;", + " while (j < n) {", + " unsigned char cj = (unsigned char)input[j];", + " if (cj >= 'A' && cj <= 'Z') {", + " j += 1;", + " } else {", + " break;", + " }", + " }", + " unsigned char after = (j < n) ? (unsigned char)input[j] : 0;", + " if (j - i >= 2 && j < n && after >= 'a' && after <= 'z') {", + " for (size_t k = i; k < j - 1; k += 1) {", + " result[out] = input[k];", + " out += 1;", + " }", + " result[out] = '_';", + " out += 1;", + " result[out] = input[j - 1];", + " out += 1;", + " result[out] = input[j];", + " out += 1;", + " i = j + 1;", + " continue;", + " }", + " for (size_t k = i; k < j; k += 1) {", + " result[out] = input[k];", + " out += 1;", + " }", + " i = j;", + " continue;", + " }", + " result[out] = input[i];", + " out += 1;", + " i += 1;", + " }", + " result[out] = 0;", + " return result;", + "}", + "", + "static char *__agent_debug_split_camel(const char *input) {", + " size_t n = strlen(input);", + " char *result = (char *)malloc(n * 2 + 1);", + " if (result == NULL) {", + " return NULL;", + " }", + " size_t out = 0;", + " for (size_t i = 0; i < n; i += 1) {", + " unsigned char ci = (unsigned char)input[i];", + " result[out] = input[i];", + " out += 1;", + " int boundary = (ci >= 'a' && ci <= 'z') || (ci >= '0' && ci <= '9');", + " if (boundary && i + 1 < n) {", + " unsigned char next = (unsigned char)input[i + 1];", + " if (next >= 'A' && next <= 'Z') {", + " result[out] = '_';", + " out += 1;", + " }", + " }", + " }", + " result[out] = 0;", + " return result;", + "}", + "", + "static char *__agent_debug_normalize_key(const char *key) {", + " char *acronym = __agent_debug_split_acronym(key);", + " if (acronym == NULL) {", + " return NULL;", + " }", + " char *split = __agent_debug_split_camel(acronym);", + " free(acronym);", + " if (split == NULL) {", + " return NULL;", + " }", + " size_t n = strlen(split);", + " char *normalized = (char *)malloc(n + 1);", + " if (normalized == NULL) {", + " free(split);", + " return NULL;", + " }", + " size_t out = 0;", + " int pending_underscore = 0;", + " for (size_t i = 0; i < n; i += 1) {", + " unsigned char c = (unsigned char)split[i];", + " int alnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');", + " if (alnum) {", + " if (pending_underscore && out > 0) {", + " normalized[out] = '_';", + " out += 1;", + " }", + " pending_underscore = 0;", + " if (c >= 'A' && c <= 'Z') {", + " normalized[out] = (char)(c - 'A' + 'a');", + " out += 1;", + " } else {", + " normalized[out] = (char)c;", + " out += 1;", + " }", + " } else {", + " pending_underscore = 1;", + " }", + " }", + " normalized[out] = 0;", + " free(split);", + " return normalized;", + "}", + "", + "static int __agent_debug_is_secret_key(const char *key) {", + " char *normalized = __agent_debug_normalize_key(key);", + " if (normalized == NULL) {", + " return 0;", + " }", + " size_t norm_len = strlen(normalized);", + " int secret = 0;", + " size_t exact_count = sizeof(AGENT_DEBUG_SECRET_KEYS) / sizeof(AGENT_DEBUG_SECRET_KEYS[0]);", + " for (size_t i = 0; i < exact_count; i += 1) {", + " if (strcmp(normalized, AGENT_DEBUG_SECRET_KEYS[i]) == 0) {", + " secret = 1;", + " break;", + " }", + " }", + " if (!secret) {", + " size_t suffix_count = sizeof(AGENT_DEBUG_SECRET_SUFFIXES) / sizeof(AGENT_DEBUG_SECRET_SUFFIXES[0]);", + " for (size_t i = 0; i < suffix_count; i += 1) {", + " const char *suffix = AGENT_DEBUG_SECRET_SUFFIXES[i];", + " size_t suffix_len = strlen(suffix);", + " if (norm_len >= suffix_len && strcmp(normalized + (norm_len - suffix_len), suffix) == 0) {", + " size_t boundary = norm_len - suffix_len;", + " if (boundary == 0 || normalized[boundary - 1] == '_') {", + " secret = 1;", + " break;", + " }", + " }", + " }", + " }", + " free(normalized);", + " return secret;", + "}", + "", + "static void __agent_debug_append(AgentDebugBuffer *buf, const char *text, size_t n) {", + " if (buf->overflow) {", + " return;", + " }", + " if (n > buf->capacity - buf->length) {", + " buf->overflow = 1;", + " return;", + " }", + " for (size_t i = 0; i < n; i += 1) {", + " buf->buffer[buf->length + i] = text[i];", + " }", + " buf->length += n;", + "}", + "", + "static void __agent_debug_append_char(AgentDebugBuffer *buf, char c) {", + " __agent_debug_append(buf, &c, 1);", + "}", + "", + "static void __agent_debug_append_ll(AgentDebugBuffer *buf, long long number) {", + " char temp[32];", + ' int written = snprintf(temp, sizeof(temp), "%lld", number);', + " if (written < 0) {", + " buf->overflow = 1;", + " return;", + " }", + " __agent_debug_append(buf, temp, (size_t)written);", + "}", + "", + "static void __agent_debug_append_double(AgentDebugBuffer *buf, double number) {", + " char temp[64];", + ' int written = snprintf(temp, sizeof(temp), "%.17g", number);', + " if (written < 0) {", + " buf->overflow = 1;", + " return;", + " }", + " __agent_debug_append(buf, temp, (size_t)written);", + "}", + "", + "static void __agent_debug_write_string(AgentDebugBuffer *buf, const char *text) {", + " char backslash = (char)0x5C;", + " char quote = (char)0x22;", + " __agent_debug_append_char(buf, quote);", + " if (text != NULL) {", + " for (const unsigned char *p = (const unsigned char *)text; *p != 0; p += 1) {", + " unsigned int code = (unsigned int)(*p);", + " char c = (char)(*p);", + " if (c == quote) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, quote);", + " } else if (c == backslash) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, backslash);", + " } else if (code == 8) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'b');", + " } else if (code == 9) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 't');", + " } else if (code == 10) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'n');", + " } else if (code == 12) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'f');", + " } else if (code == 13) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'r');", + " } else if (code < 32) {", + ' const char *digits = "0123456789abcdef";', + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'u');", + " __agent_debug_append_char(buf, '0');", + " __agent_debug_append_char(buf, '0');", + " __agent_debug_append_char(buf, digits[(code >> 4) & 15]);", + " __agent_debug_append_char(buf, digits[code & 15]);", + " } else {", + " __agent_debug_append_char(buf, c);", + " }", + " }", + " }", + " __agent_debug_append_char(buf, quote);", + "}", + "", + "static int __agent_debug_write_value(AgentDebugBuffer *buf, const AgentValue *value, int depth) {", + " if (depth > 64) {", + " return 0;", + " }", + " if (value == NULL) {", + " return 0;", + " }", + " switch (value->kind) {", + " case AGENT_KIND_NULL:", + ' __agent_debug_append(buf, "null", 4);', + " break;", + " case AGENT_KIND_BOOL:", + " if (value->boolValue) {", + ' __agent_debug_append(buf, "true", 4);', + " } else {", + ' __agent_debug_append(buf, "false", 5);', + " }", + " break;", + " case AGENT_KIND_INT:", + " __agent_debug_append_ll(buf, value->intValue);", + " break;", + " case AGENT_KIND_DOUBLE:", + " if (!isfinite(value->doubleValue)) {", + " return 0;", + " }", + " __agent_debug_append_double(buf, value->doubleValue);", + " break;", + " case AGENT_KIND_STR:", + " __agent_debug_write_string(buf, value->strValue);", + " break;", + " case AGENT_KIND_ARRAY: {", + " __agent_debug_append_char(buf, '[');", + " for (size_t i = 0; i < value->count; i += 1) {", + " if (i > 0) {", + " __agent_debug_append_char(buf, ',');", + " }", + " if (!__agent_debug_write_value(buf, value->items[i], depth + 1)) {", + " return 0;", + " }", + " }", + " __agent_debug_append_char(buf, ']');", + " break;", + " }", + " case AGENT_KIND_OBJECT: {", + " __agent_debug_append_char(buf, '{');", + " for (size_t i = 0; i < value->count; i += 1) {", + " if (i > 0) {", + " __agent_debug_append_char(buf, ',');", + " }", + " __agent_debug_write_string(buf, value->keys[i]);", + " __agent_debug_append_char(buf, ':');", + " if (__agent_debug_is_secret_key(value->keys[i])) {", + ' __agent_debug_write_string(buf, "[REDACTED]");', + " } else if (!__agent_debug_write_value(buf, value->items[i], depth + 1)) {", + " return 0;", + " }", + " }", + " __agent_debug_append_char(buf, '}');", + " break;", + " }", + " }", + " return 1;", + "}", + "", + "static long long __agent_debug_timestamp_ms(void) {", + "#ifdef _WIN32", + " return (long long)time(NULL) * 1000;", + "#else", + " struct timespec ts;", + " if (clock_gettime(CLOCK_REALTIME, &ts) != 0) {", + " return 0;", + " }", + " return (long long)ts.tv_sec * 1000 + (long long)ts.tv_nsec / 1000000;", + "#endif", + "}", + "", + "static void __agentDebugEmit(const char *hypothesisId, const char *location,", + " const char *message, AgentValue *data) {", + " char storage[65536 + 4096];", + " AgentDebugBuffer buf;", + " buf.buffer = storage;", + " buf.length = 0;", + " buf.capacity = sizeof(storage);", + " buf.overflow = 0;", + " int serialized = 1;", + " __agent_debug_append_char(&buf, '{');", + ' __agent_debug_write_string(&buf, "hypothesisId");', + " __agent_debug_append_char(&buf, ':');", + " __agent_debug_write_string(&buf, hypothesisId);", + " __agent_debug_append_char(&buf, ',');", + ' __agent_debug_write_string(&buf, "location");', + " __agent_debug_append_char(&buf, ':');", + " __agent_debug_write_string(&buf, location);", + " __agent_debug_append_char(&buf, ',');", + ' __agent_debug_write_string(&buf, "message");', + " __agent_debug_append_char(&buf, ':');", + " __agent_debug_write_string(&buf, message);", + " __agent_debug_append_char(&buf, ',');", + ' __agent_debug_write_string(&buf, "data");', + " __agent_debug_append_char(&buf, ':');", + " if (!__agent_debug_write_value(&buf, data, 0)) {", + " serialized = 0;", + " }", + " if (serialized) {", + " __agent_debug_append_char(&buf, ',');", + ' __agent_debug_write_string(&buf, "timestamp");', + " __agent_debug_append_char(&buf, ':');", + " __agent_debug_append_ll(&buf, __agent_debug_timestamp_ms());", + " __agent_debug_append_char(&buf, '}');", + " __agent_debug_append_char(&buf, (char)0x0A);", + " }", + " if (serialized && !buf.overflow && buf.length <= 65536) {", + "#ifdef _WIN32", + ' FILE *file = fopen("__APPEND_PATH__", "ab");', + " if (file != NULL) {", + " size_t wrote = fwrite(storage, 1, buf.length, file);", + " (void)wrote;", + " fclose(file);", + " }", + "#else", + ' int fd = open("__APPEND_PATH__", O_WRONLY | O_APPEND | O_CREAT, 0600);', + " if (fd >= 0) {", + " ssize_t wrote = write(fd, storage, buf.length);", + " (void)wrote;", + " close(fd);", + " }", + "#endif", + " }", + " __agent_debug_free(data);", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "c", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible C AgentValue that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/src/probes/render.ts b/src/probes/render.ts index f1e488d..b03ae53 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -1,3 +1,4 @@ +import { renderCTemplate } from "./c"; import { renderCppTemplate } from "./cpp"; import { renderCSharpTemplate } from "./csharp"; import { renderGoTemplate } from "./go"; @@ -21,7 +22,8 @@ export type TemplateLanguage = | "csharp" | "swift" | "rust" - | "cpp"; + | "cpp" + | "c"; export type IngestMethod = "http" | "file"; @@ -88,6 +90,8 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { case "c++": case "cxx": return "cpp"; + case "c": + return "c"; default: return undefined; } @@ -124,6 +128,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderRustTemplate(); case "cpp:file": return renderCppTemplate(); + case "c:file": + return renderCTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 4661b64..acf5b52 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -21,6 +21,7 @@ const supported = [ ["swift", "file"], ["rust", "file"], ["cpp", "file"], + ["c", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -116,6 +117,8 @@ describe("session-independent template renderers", () => { expect(renderTemplate("CPP", "FILE").language).toBe("cpp"); expect(renderTemplate("C++", "file").language).toBe("cpp"); expect(renderTemplate("cxx", "file").language).toBe("cpp"); + expect(renderTemplate("C", "FILE").language).toBe("c"); + expect(renderTemplate("c", "file").language).toBe("c"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -131,6 +134,7 @@ describe("session-independent template renderers", () => { ["swift", "http"], ["rust", "http"], ["cpp", "http"], + ["c", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index da297d6..efaaf7a 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -253,6 +253,33 @@ const fixtures: Fixture[] = [ sharedData: 'AgentValue{ {"left", __agent_shared}, {"right", __agent_shared} }', sharedPrelude: 'AgentValue __agent_shared = AgentValue{ {"APIKey", "source-shared-secret"} };', }, + { + callData: + 'adbg_obj("value", adbg_int(42), "designToken", adbg_str("visible-design-token"), "fortuneCookie", adbg_str("visible-fortune-cookie"), "secretSauceName", adbg_str("visible-secret-sauce"), "tokenCount", adbg_int(7), "passwordPolicy", adbg_str("visible-password-policy"), "password", adbg_str("source-password-secret"), "APIKey", adbg_str("source-api-acronym-secret"), "APIToken", adbg_str("source-api-token-secret"), "IDToken", adbg_str("source-id-token-secret"), "OAuthToken", adbg_str("source-oauth-token-secret"), "Client Secret", adbg_str("source-client-secret"), "nested", adbg_obj("apiKey", adbg_str("source-api-secret"), "items", adbg_arr(adbg_obj("refresh-token", adbg_str("source-refresh-secret"), NULL), adbg_obj("credentials", adbg_str("source-credentials-secret"), NULL), NULL), NULL), NULL)', + // C's AgentValue is an owned pointer tree, so a reference cycle is + // unrepresentable. The depth-64 cap is the analogous unbounded-structure + // rejection: a value nested past the cap is dropped without emitting, + // exactly like a cycle would be. + command: (path) => { + const compiler = Bun.which("clang") ?? Bun.which("gcc") ?? ""; + const binary = path.replace(/\.c$/, process.platform === "win32" ? ".exe" : ".out"); + const script = `"${compiler}" -std=c99 "${path}" -o "${binary}" && "${binary}"`; + return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + }, + cycleData: "__agent_cycle", + cyclePrelude: + "AgentValue *__agent_cycle = adbg_int(0); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { __agent_cycle = adbg_arr(__agent_cycle, NULL); }", + file: "c-file.c", + ingest: "file", + language: "c", + runtime: Bun.which("clang") ?? Bun.which("gcc"), + // C stores an owned pointer tree; sharing one pointer twice would double-free + // on cleanup, so the shared-reference case builds two independent equal + // subtrees (the test compares output equality, not pointer identity). + sharedData: + 'adbg_obj("left", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), "right", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), NULL)', + sharedPrelude: "", + }, ]; async function run(command: string[], env: Record = process.env) { diff --git a/tests/fixtures/languages/c-file.c b/tests/fixtures/languages/c-file.c new file mode 100644 index 0000000..2c5e1d5 --- /dev/null +++ b/tests/fixtures/languages/c-file.c @@ -0,0 +1,7 @@ +__HELPER_TEMPLATE__ + +int main(void) { + __CALL_TEMPLATE__ + printf("application-completed\n"); + return 0; +} From f52df5922b1c93f037b09c21e30596eb58c751c0 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 13:41:21 -0700 Subject: [PATCH 4/6] feat: add Java probe template Add a Java (file-ingest) probe template cloning the csharp.ts approach: real java.util.regex key normalization, Map/List/array/scalar handling with a java.lang.reflect fallback over public getters and fields, IdentityHashMap-based cycle detection with a depth-64 cap, fully-qualified names (no imports) so the helper is injectable anywhere, and single-file `java .java` execution. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 5 +- README.md | 1 + skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 1 + specs/building-a-debug-mode-agent.md | 3 +- src/probes/java.ts | 274 ++++++++++++++++++++++ src/probes/render.ts | 8 +- tests/contract/template-renderers.test.ts | 4 + tests/e2e/languages/live-probes.test.ts | 124 +++++++--- tests/fixtures/languages/java-file.java | 9 + 10 files changed, 388 insertions(+), 42 deletions(-) create mode 100644 src/probes/java.ts create mode 100644 tests/fixtures/languages/java-file.java diff --git a/DESIGN.md b/DESIGN.md index 759a87e..a36eeb7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -118,9 +118,10 @@ The first supported combinations are: - Rust + file - C++ + file - C + file +- Java + file -Every advertised combination must pass a live end-to-end test with its real runtime. Java, Kotlin, -and shell are not advertised until a safe serializer contract is defined. +Every advertised combination must pass a live end-to-end test with its real runtime. Kotlin and +shell are not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/README.md b/README.md index c2438ca..5763325 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ runtime: | Rust | file | | C++ | file | | C | file | +| Java | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index 8f7fd9a..166e8d1 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -34,6 +34,7 @@ schema** for one language and transport. Templates never take `--session`. | Rust | `rust` (`rs`) | `file` | | C++ | `cpp` (`c++`, `cxx`) | `file` | | C | `c` | `file` | +| Java | `java` | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index 66f3925..f76dba4 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -116,6 +116,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | Go | file | C# | file | | Swift | file | Rust | file | | C++ | file | C | file | +| Java | file | | | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index db79f8b..d8be185 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -149,6 +149,7 @@ advertised, end-to-end-tested combinations are: - Rust + file - C++ + file - C + file +- Java + file Other languages are not advertised until a safe serializer contract is defined. @@ -269,7 +270,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift, Rust, C++, C | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust, C++, C, Java | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/java.ts b/src/probes/java.ts new file mode 100644 index 0000000..a26c887 --- /dev/null +++ b/src/probes/java.ts @@ -0,0 +1,274 @@ +import type { ProbeTemplates } from "./render"; + +export function renderJavaTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + 'AgentDebugLog.Emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "class AgentDebugLog {", + " private static final java.util.Set SECRET_KEYS = new java.util.HashSet<>(java.util.Arrays.asList(", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials"));', + "", + " private static final java.util.regex.Pattern SECRET_SUFFIX = java.util.regex.Pattern.compile(", + ' "(^|_)(api_key|api_token|oauth_token|o_auth_token|private_key|client_secret|access_token|refresh_token|id_token|auth_token|bearer_token)$");', + "", + " private static boolean isSecretKey(String key) {", + ' String normalized = key.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2");', + ' normalized = normalized.replaceAll("([a-z0-9])([A-Z])", "$1_$2");', + ' normalized = normalized.replaceAll("[^a-zA-Z0-9]+", "_").toLowerCase(java.util.Locale.ROOT);', + ' normalized = normalized.replaceAll("^_+", "").replaceAll("_+$", "");', + " return SECRET_KEYS.contains(normalized) || SECRET_SUFFIX.matcher(normalized).find();", + " }", + "", + " private static String decapitalize(String name) {", + " if (name.isEmpty()) {", + " return name;", + " }", + " if (name.length() > 1 && Character.isUpperCase(name.charAt(0)) && Character.isUpperCase(name.charAt(1))) {", + " return name;", + " }", + " char[] letters = name.toCharArray();", + " letters[0] = Character.toLowerCase(letters[0]);", + " return new String(letters);", + " }", + "", + " private static Object redact(Object value, java.util.IdentityHashMap active, int depth) {", + " if (depth > 64) {", + ' throw new IllegalStateException("Observation graph is too deep");', + " }", + " if (value == null) {", + " return null;", + " }", + " if (value instanceof CharSequence || value instanceof Character) {", + " return value.toString();", + " }", + " if (value instanceof Number || value instanceof Boolean) {", + " return value;", + " }", + " if (value instanceof Enum) {", + " return ((Enum) value).name();", + " }", + " if (active.put(value, Boolean.TRUE) != null) {", + ' throw new IllegalStateException("Cyclic observation graph");', + " }", + " try {", + " if (value instanceof java.util.Map) {", + " java.util.LinkedHashMap redacted = new java.util.LinkedHashMap<>();", + " for (java.util.Map.Entry entry : ((java.util.Map) value).entrySet()) {", + " String key = String.valueOf(entry.getKey());", + ' redacted.put(key, isSecretKey(key) ? "[REDACTED]" : redact(entry.getValue(), active, depth + 1));', + " }", + " return redacted;", + " }", + " if (value instanceof Iterable) {", + " java.util.ArrayList redacted = new java.util.ArrayList<>();", + " for (Object item : (Iterable) value) {", + " redacted.add(redact(item, active, depth + 1));", + " }", + " return redacted;", + " }", + " if (value.getClass().isArray()) {", + " java.util.ArrayList redacted = new java.util.ArrayList<>();", + " int length = java.lang.reflect.Array.getLength(value);", + " for (int index = 0; index < length; index += 1) {", + " redacted.add(redact(java.lang.reflect.Array.get(value, index), active, depth + 1));", + " }", + " return redacted;", + " }", + " java.util.LinkedHashMap members = new java.util.LinkedHashMap<>();", + " Class type = value.getClass();", + " for (java.lang.reflect.Method method : type.getMethods()) {", + " if (method.getParameterCount() != 0 || method.getReturnType() == void.class) {", + " continue;", + " }", + " if (java.lang.reflect.Modifier.isStatic(method.getModifiers()) || method.getDeclaringClass() == Object.class) {", + " continue;", + " }", + " String name = method.getName();", + " String property;", + ' if (name.startsWith("get") && name.length() > 3) {', + " property = decapitalize(name.substring(3));", + ' } else if (name.startsWith("is") && name.length() > 2 && (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) {', + " property = decapitalize(name.substring(2));", + " } else {", + " continue;", + " }", + " Object raw;", + " try {", + " method.setAccessible(true);", + " raw = method.invoke(value);", + " } catch (Throwable failure) {", + " continue;", + " }", + ' members.put(property, isSecretKey(property) ? "[REDACTED]" : redact(raw, active, depth + 1));', + " }", + " for (java.lang.reflect.Field field : type.getFields()) {", + " if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) {", + " continue;", + " }", + " Object raw;", + " try {", + " field.setAccessible(true);", + " raw = field.get(value);", + " } catch (Throwable failure) {", + " continue;", + " }", + ' members.put(field.getName(), isSecretKey(field.getName()) ? "[REDACTED]" : redact(raw, active, depth + 1));', + " }", + " return members;", + " } finally {", + " active.remove(value);", + " }", + " }", + "", + " private static void writeString(StringBuilder out, String text) {", + " char backslash = (char) 92;", + " char quote = (char) 34;", + " out.append(quote);", + " for (int index = 0; index < text.length(); index += 1) {", + " char symbol = text.charAt(index);", + " if (symbol == quote) {", + " out.append(backslash).append(quote);", + " } else if (symbol == backslash) {", + " out.append(backslash).append(backslash);", + " } else if (symbol == (char) 8) {", + " out.append(backslash).append('b');", + " } else if (symbol == (char) 9) {", + " out.append(backslash).append('t');", + " } else if (symbol == (char) 10) {", + " out.append(backslash).append('n');", + " } else if (symbol == (char) 12) {", + " out.append(backslash).append('f');", + " } else if (symbol == (char) 13) {", + " out.append(backslash).append('r');", + " } else if (symbol < (char) 32) {", + ' String digits = "0123456789abcdef";', + " out.append(backslash).append('u').append('0').append('0');", + " out.append(digits.charAt((symbol >> 4) & 15)).append(digits.charAt(symbol & 15));", + " } else {", + " out.append(symbol);", + " }", + " }", + " out.append(quote);", + " }", + "", + " private static void writeValue(StringBuilder out, Object value) {", + " if (value == null) {", + ' out.append("null");', + " return;", + " }", + " if (value instanceof String) {", + " writeString(out, (String) value);", + " return;", + " }", + " if (value instanceof Boolean) {", + ' out.append(((Boolean) value).booleanValue() ? "true" : "false");', + " return;", + " }", + " if (value instanceof Double || value instanceof Float) {", + " double number = ((Number) value).doubleValue();", + " if (Double.isNaN(number) || Double.isInfinite(number)) {", + ' throw new IllegalStateException("Non-finite number");', + " }", + " out.append(Double.toString(number));", + " return;", + " }", + " if (value instanceof Number) {", + " out.append(value.toString());", + " return;", + " }", + " if (value instanceof java.util.Map) {", + " out.append('{');", + " boolean first = true;", + " for (java.util.Map.Entry entry : ((java.util.Map) value).entrySet()) {", + " if (!first) {", + " out.append(',');", + " }", + " first = false;", + " writeString(out, String.valueOf(entry.getKey()));", + " out.append(':');", + " writeValue(out, entry.getValue());", + " }", + " out.append('}');", + " return;", + " }", + " if (value instanceof java.util.List) {", + " out.append('[');", + " boolean first = true;", + " for (Object item : (java.util.List) value) {", + " if (!first) {", + " out.append(',');", + " }", + " first = false;", + " writeValue(out, item);", + " }", + " out.append(']');", + " return;", + " }", + " writeString(out, String.valueOf(value));", + " }", + "", + " static void Emit(String hypothesisId, String location, String message, Object data) {", + " try {", + " StringBuilder out = new StringBuilder();", + " out.append('{');", + ' writeString(out, "hypothesisId");', + " out.append(':');", + " writeString(out, hypothesisId);", + " out.append(',');", + ' writeString(out, "location");', + " out.append(':');", + " writeString(out, location);", + " out.append(',');", + ' writeString(out, "message");', + " out.append(':');", + " writeString(out, message);", + " out.append(',');", + ' writeString(out, "data");', + " out.append(':');", + " writeValue(out, redact(data, new java.util.IdentityHashMap(), 0));", + " out.append(',');", + ' writeString(out, "timestamp");', + " out.append(':');", + " out.append(System.currentTimeMillis());", + " out.append('}');", + " out.append((char) 10);", + " byte[] payload = out.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);", + " if (payload.length > 65536) {", + " return;", + " }", + ' java.nio.file.Path path = java.nio.file.Paths.get("__APPEND_PATH__");', + " try {", + ' java.nio.file.Files.createFile(path, java.nio.file.attribute.PosixFilePermissions.asFileAttribute(java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")));', + " } catch (UnsupportedOperationException nonPosix) {", + " try {", + " java.nio.file.Files.createFile(path);", + " } catch (Throwable ignored) {", + " }", + " } catch (Throwable ignored) {", + " }", + " java.nio.file.Files.write(path, payload, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);", + " } catch (Throwable failure) {", + " // Observations must never change application behavior.", + " }", + " }", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "java", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible Java value that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/src/probes/render.ts b/src/probes/render.ts index b03ae53..9208716 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -2,6 +2,7 @@ import { renderCTemplate } from "./c"; import { renderCppTemplate } from "./cpp"; import { renderCSharpTemplate } from "./csharp"; import { renderGoTemplate } from "./go"; +import { renderJavaTemplate } from "./java"; import { renderJavaScriptTemplate } from "./javascript"; import { renderPhpTemplate } from "./php"; import { renderPowerShellTemplate } from "./powershell"; @@ -23,7 +24,8 @@ export type TemplateLanguage = | "swift" | "rust" | "cpp" - | "c"; + | "c" + | "java"; export type IngestMethod = "http" | "file"; @@ -92,6 +94,8 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { return "cpp"; case "c": return "c"; + case "java": + return "java"; default: return undefined; } @@ -130,6 +134,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderCppTemplate(); case "c:file": return renderCTemplate(); + case "java:file": + return renderJavaTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index acf5b52..6ce91c7 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -22,6 +22,7 @@ const supported = [ ["rust", "file"], ["cpp", "file"], ["c", "file"], + ["java", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -119,6 +120,8 @@ describe("session-independent template renderers", () => { expect(renderTemplate("cxx", "file").language).toBe("cpp"); expect(renderTemplate("C", "FILE").language).toBe("c"); expect(renderTemplate("c", "file").language).toBe("c"); + expect(renderTemplate("Java", "FILE").language).toBe("java"); + expect(renderTemplate("java", "file").language).toBe("java"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -135,6 +138,7 @@ describe("session-independent template renderers", () => { ["rust", "http"], ["cpp", "http"], ["c", "http"], + ["java", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index efaaf7a..00b4e25 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -280,6 +280,21 @@ const fixtures: Fixture[] = [ 'adbg_obj("left", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), "right", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), NULL)', sharedPrelude: "", }, + { + callData: + 'java.util.Map.ofEntries(java.util.Map.entry("value", 42), java.util.Map.entry("designToken", "visible-design-token"), java.util.Map.entry("fortuneCookie", "visible-fortune-cookie"), java.util.Map.entry("secretSauceName", "visible-secret-sauce"), java.util.Map.entry("tokenCount", 7), java.util.Map.entry("passwordPolicy", "visible-password-policy"), java.util.Map.entry("password", "source-password-secret"), java.util.Map.entry("APIKey", "source-api-acronym-secret"), java.util.Map.entry("APIToken", "source-api-token-secret"), java.util.Map.entry("IDToken", "source-id-token-secret"), java.util.Map.entry("OAuthToken", "source-oauth-token-secret"), java.util.Map.entry("Client Secret", "source-client-secret"), java.util.Map.entry("nested", java.util.Map.ofEntries(java.util.Map.entry("apiKey", "source-api-secret"), java.util.Map.entry("items", java.util.List.of(java.util.Map.of("refresh-token", "source-refresh-secret"), java.util.Map.of("credentials", "source-credentials-secret"))))))', + command: (path) => [Bun.which("java") ?? "", path], + cycleData: "__agentCycle", + cyclePrelude: + 'java.util.Map __agentCycle = new java.util.LinkedHashMap<>(); __agentCycle.put("self", __agentCycle);', + file: "java-file.java", + ingest: "file", + language: "java", + runtime: Bun.which("java"), + sharedData: 'java.util.Map.of("left", __agentShared, "right", __agentShared)', + sharedPrelude: + 'java.util.Map __agentShared = new java.util.LinkedHashMap<>(); __agentShared.put("APIKey", "source-shared-secret");', + }, ]; async function run(command: string[], env: Record = process.env) { @@ -800,10 +815,73 @@ describe("live language templates", () => { }, 120_000); } - for (const language of ["csharp", "powershell"]) { + // Per-language custom-object coverage: C# and PowerShell exercise value types + // (structs) so the redactor inspects their members instead of treating them as + // atomic; Java has no value types, so it exercises the reflection path over a + // plain object's public fields — the only case that reaches the reflection + // branch, since the shared policy matrix uses maps and lists throughout. + const valueTypeCases: Record< + string, + { dataExpression: string; prelude: string; extraTypes?: string } + > = { + csharp: { + dataExpression: "__agentValue", + prelude: [ + "var __agentValue = new AgentCustomValue", + "{", + ' APIKey = "source-value-api-secret",', + ' designToken = "visible-value-design-token",', + ' Nested = new Dictionary { ["OAuthToken"] = "source-value-oauth-secret" },', + "};", + ].join("\n"), + extraTypes: [ + "internal struct AgentCustomValue", + "{", + " public string APIKey { get; init; }", + " public Dictionary Nested;", + " public string designToken;", + "}", + ].join("\n"), + }, + java: { + dataExpression: "__agentValue", + prelude: [ + "AgentCustomValue __agentValue = new AgentCustomValue();", + '__agentValue.APIKey = "source-value-api-secret";', + '__agentValue.designToken = "visible-value-design-token";', + '__agentValue.Nested = java.util.Map.of("OAuthToken", "source-value-oauth-secret");', + ].join("\n"), + extraTypes: [ + "static class AgentCustomValue {", + " public String APIKey;", + " public Object Nested;", + " public String designToken;", + "}", + ].join("\n"), + }, + powershell: { + dataExpression: "$__agentValue", + prelude: [ + "Add-Type -TypeDefinition @'", + "public struct AgentCustomValue {", + " public string APIKey { get; set; }", + " public object Nested { get; set; }", + " public string designToken;", + "}", + "'@", + "$__agentValue = [AgentCustomValue]::new()", + '$__agentValue.APIKey = "source-value-api-secret"', + '$__agentValue.designToken = "visible-value-design-token"', + '$__agentValue.Nested = @{ OAuthToken = "source-value-oauth-secret" }', + ].join("\n"), + }, + }; + + for (const language of ["csharp", "powershell", "java"]) { const fixture = fixtures.find((candidate) => candidate.language === language); const unavailable = fixture?.runtime === null; const runtimeTest = unavailable && !requireRuntimes ? test.skip : test; + const valueTypeCase = valueTypeCases[language]; runtimeTest( `${language} redacts custom value-type members`, @@ -811,6 +889,9 @@ describe("live language templates", () => { if (!fixture) { throw new Error(`Missing ${language} fixture`); } + if (!valueTypeCase) { + throw new Error(`Missing ${language} value-type case`); + } expect(fixture.runtime, `${language} runtime must be installed`).not.toBeNull(); const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); const workspace = await mkdtemp(join(tmpdir(), `debug-mode-${language}-value-type-`)); @@ -823,41 +904,8 @@ describe("live language templates", () => { ); await fixture.setup?.(workspace); const fixturePath = join(workspace, fixture.file); - const isCSharp = language === "csharp"; - const prelude = isCSharp - ? [ - "var __agentValue = new AgentCustomValue", - "{", - ' APIKey = "source-value-api-secret",', - ' designToken = "visible-value-design-token",', - ' Nested = new Dictionary { ["OAuthToken"] = "source-value-oauth-secret" },', - "};", - ].join("\n") - : [ - "Add-Type -TypeDefinition @'", - "public struct AgentCustomValue {", - " public string APIKey { get; set; }", - " public object Nested { get; set; }", - " public string designToken;", - "}", - "'@", - "$__agentValue = [AgentCustomValue]::new()", - '$__agentValue.APIKey = "source-value-api-secret"', - '$__agentValue.designToken = "visible-value-design-token"', - '$__agentValue.Nested = @{ OAuthToken = "source-value-oauth-secret" }', - ].join("\n"); - if (isCSharp) { - source = source.replace( - "/* __EXTRA_TYPES__ */", - [ - "internal struct AgentCustomValue", - "{", - " public string APIKey { get; init; }", - " public Dictionary Nested;", - " public string designToken;", - "}", - ].join("\n"), - ); + if (valueTypeCase.extraTypes) { + source = source.replace("/* __EXTRA_TYPES__ */", valueTypeCase.extraTypes); } await writeFile( fixturePath, @@ -867,8 +915,8 @@ describe("live language templates", () => { fixture, created.data.appendPath.replaceAll("\\", "\\\\"), 1, - isCSharp ? "__agentValue" : "$__agentValue", - prelude, + valueTypeCase.dataExpression, + valueTypeCase.prelude, ), ); diff --git a/tests/fixtures/languages/java-file.java b/tests/fixtures/languages/java-file.java new file mode 100644 index 0000000..640aa0e --- /dev/null +++ b/tests/fixtures/languages/java-file.java @@ -0,0 +1,9 @@ +class JavaFile { + /* __EXTRA_TYPES__ */ + public static void main(String[] args) { + __CALL_TEMPLATE__ + System.out.println("application-completed"); + } +} + +__HELPER_TEMPLATE__ From b87f96c0165749b4bdf69663984ebf0b6b9c9379 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 13:55:13 -0700 Subject: [PATCH 5/6] feat: add Kotlin probe template Add the Kotlin (file ingest) probe template, closing the five-language systems batch (C, C++, Rust, Java, Kotlin). Kotlin clones the Java JVM approach: java.lang.reflect over public getters then fields (not kotlin-reflect), IdentityHashMap cycle detection, depth cap 64, real java.util.regex secret-key normalization, Files.write(CREATE, APPEND) with 0600-at-create POSIX handling, and an object AgentDebugLog.emit static helper that never alters control flow (catch Throwable). Also updates the changeset (minor bump: nine helper/runtime pairs become fourteen) and every current-facing language matrix and count (README, DESIGN, SKILL, REFERENCE, spec advertised list + region-marker row). Co-Authored-By: Claude Fable 5 --- .changeset/systems-language-templates.md | 7 + DESIGN.md | 1 + README.md | 3 +- skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 2 +- specs/building-a-debug-mode-agent.md | 3 +- src/probes/kotlin.ts | 280 ++++++++++++++++++++++ src/probes/render.ts | 9 +- tests/contract/template-renderers.test.ts | 4 + tests/e2e/languages/live-probes.test.ts | 52 +++- tests/fixtures/languages/kotlin-file.kt | 7 + 11 files changed, 358 insertions(+), 11 deletions(-) create mode 100644 .changeset/systems-language-templates.md create mode 100644 src/probes/kotlin.ts create mode 100644 tests/fixtures/languages/kotlin-file.kt diff --git a/.changeset/systems-language-templates.md b/.changeset/systems-language-templates.md new file mode 100644 index 0000000..c299281 --- /dev/null +++ b/.changeset/systems-language-templates.md @@ -0,0 +1,7 @@ +--- +"agentic-debug-mode": minor +--- + +Add probe templates for five systems languages: C, C++, Rust, Java, and Kotlin. Each renders a +hypothesis-tagged, at-source-redacting fire-and-forget helper that appends bounded NDJSON evidence, +bringing the advertised helper/runtime pairs from nine to fourteen. diff --git a/DESIGN.md b/DESIGN.md index a36eeb7..ec590a2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -119,6 +119,7 @@ The first supported combinations are: - C++ + file - C + file - Java + file +- Kotlin + file Every advertised combination must pass a live end-to-end test with its real runtime. Kotlin and shell are not advertised until a safe serializer contract is defined. diff --git a/README.md b/README.md index 5763325..8eb9254 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ reproduces the failure, and proves the root cause with runtime evidence before i Inspired from [Cursor's debug mode](https://cursor.com/blog/debug-mode) and built upon it: the same hypothesis-driven core, made agent-agnostic (any coding agent that can run a CLI), and extended -with a session-scoped evidence store, hypothesis-tagged probes across nine languages, embedded +with a session-scoped evidence store, hypothesis-tagged probes across fourteen languages, embedded structured queries over captured events, at-source secret redaction, and bounded reads that keep large evidence sets token-cheap. @@ -90,6 +90,7 @@ runtime: | C++ | file | | C | file | | Java | file | +| Kotlin | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index 166e8d1..18f8540 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -35,6 +35,7 @@ schema** for one language and transport. Templates never take `--session`. | C++ | `cpp` (`c++`, `cxx`) | `file` | | C | `c` | `file` | | Java | `java` | `file` | +| Kotlin | `kotlin` (`kt`) | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index f76dba4..615eac8 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -116,7 +116,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | Go | file | C# | file | | Swift | file | Rust | file | | C++ | file | C | file | -| Java | file | | | +| Java | file | Kotlin | file | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index d8be185..ab7dcde 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -150,6 +150,7 @@ advertised, end-to-end-tested combinations are: - C++ + file - C + file - Java + file +- Kotlin + file Other languages are not advertised until a safe serializer contract is defined. @@ -270,7 +271,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift, Rust, C++, C, Java | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust, C++, C, Java, Kotlin | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/kotlin.ts b/src/probes/kotlin.ts new file mode 100644 index 0000000..defa3fa --- /dev/null +++ b/src/probes/kotlin.ts @@ -0,0 +1,280 @@ +import type { ProbeTemplates } from "./render"; + +export function renderKotlinTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + 'AgentDebugLog.emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__)', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "object AgentDebugLog {", + " private val SECRET_KEYS = java.util.HashSet(java.util.Arrays.asList(", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials"))', + "", + ' private val SECRET_BOUNDARY_ONE = java.util.regex.Pattern.compile("([A-Z]+)([A-Z][a-z])")', + ' private val SECRET_BOUNDARY_TWO = java.util.regex.Pattern.compile("([a-z0-9])([A-Z])")', + ' private val SECRET_NON_ALNUM = java.util.regex.Pattern.compile("[^a-zA-Z0-9]+")', + ' private val SECRET_TRIM_LEADING = java.util.regex.Pattern.compile("^_+")', + ' private val SECRET_TRIM_TRAILING = java.util.regex.Pattern.compile("_+\\$")', + " private val SECRET_SUFFIX = java.util.regex.Pattern.compile(", + ' "(^|_)(api_key|api_token|oauth_token|o_auth_token|private_key|client_secret|access_token|refresh_token|id_token|auth_token|bearer_token)\\$")', + "", + " private fun isSecretKey(key: String): Boolean {", + ' var normalized = SECRET_BOUNDARY_ONE.matcher(key).replaceAll("\\$1_\\$2")', + ' normalized = SECRET_BOUNDARY_TWO.matcher(normalized).replaceAll("\\$1_\\$2")', + ' normalized = SECRET_NON_ALNUM.matcher(normalized).replaceAll("_").lowercase()', + ' normalized = SECRET_TRIM_LEADING.matcher(normalized).replaceAll("")', + ' normalized = SECRET_TRIM_TRAILING.matcher(normalized).replaceAll("")', + " return SECRET_KEYS.contains(normalized) || SECRET_SUFFIX.matcher(normalized).find()", + " }", + "", + " private fun decapitalize(name: String): String {", + " if (name.isEmpty()) {", + " return name", + " }", + " if (name.length > 1 && name[0].isUpperCase() && name[1].isUpperCase()) {", + " return name", + " }", + " val letters = name.toCharArray()", + " letters[0] = letters[0].lowercaseChar()", + " return String(letters)", + " }", + "", + " private fun redact(value: Any?, active: java.util.IdentityHashMap, depth: Int): Any? {", + " if (depth > 64) {", + ' throw IllegalStateException("Observation graph is too deep")', + " }", + " if (value == null) {", + " return null", + " }", + " if (value is CharSequence || value is Char) {", + " return value.toString()", + " }", + " if (value is Number || value is Boolean) {", + " return value", + " }", + " if (value is Enum<*>) {", + " return value.name", + " }", + " if (active.put(value, true) != null) {", + ' throw IllegalStateException("Cyclic observation graph")', + " }", + " try {", + " if (value is Map<*, *>) {", + " val redacted = java.util.LinkedHashMap()", + " for (entry in value.entries) {", + ' val key = entry.key?.toString() ?: "null"', + ' redacted[key] = if (isSecretKey(key)) "[REDACTED]" else redact(entry.value, active, depth + 1)', + " }", + " return redacted", + " }", + " if (value is Iterable<*>) {", + " val redacted = java.util.ArrayList()", + " for (item in value) {", + " redacted.add(redact(item, active, depth + 1))", + " }", + " return redacted", + " }", + " if (value.javaClass.isArray) {", + " val redacted = java.util.ArrayList()", + " val length = java.lang.reflect.Array.getLength(value)", + " for (index in 0 until length) {", + " redacted.add(redact(java.lang.reflect.Array.get(value, index), active, depth + 1))", + " }", + " return redacted", + " }", + " val members = java.util.LinkedHashMap()", + " val type = value.javaClass", + " for (method in type.methods) {", + " if (method.parameterCount != 0 || method.returnType == java.lang.Void.TYPE) {", + " continue", + " }", + " if (java.lang.reflect.Modifier.isStatic(method.modifiers) || method.declaringClass == java.lang.Object::class.java) {", + " continue", + " }", + " val name = method.name", + " val property: String", + ' if (name.startsWith("get") && name.length > 3) {', + " property = decapitalize(name.substring(3))", + ' } else if (name.startsWith("is") && name.length > 2 && (method.returnType == java.lang.Boolean.TYPE || method.returnType == java.lang.Boolean::class.java)) {', + " property = decapitalize(name.substring(2))", + " } else {", + " continue", + " }", + " val raw: Any?", + " try {", + " method.isAccessible = true", + " raw = method.invoke(value)", + " } catch (methodFailure: Throwable) {", + " continue", + " }", + ' members[property] = if (isSecretKey(property)) "[REDACTED]" else redact(raw, active, depth + 1)', + " }", + " for (field in type.fields) {", + " if (java.lang.reflect.Modifier.isStatic(field.modifiers)) {", + " continue", + " }", + " val raw: Any?", + " try {", + " field.isAccessible = true", + " raw = field.get(value)", + " } catch (fieldFailure: Throwable) {", + " continue", + " }", + ' members[field.name] = if (isSecretKey(field.name)) "[REDACTED]" else redact(raw, active, depth + 1)', + " }", + " return members", + " } finally {", + " active.remove(value)", + " }", + " }", + "", + " private fun writeString(out: StringBuilder, text: String) {", + " val backslash = 92.toChar()", + " val quote = 34.toChar()", + " out.append(quote)", + " for (index in 0 until text.length) {", + " val symbol = text[index]", + " if (symbol == quote) {", + " out.append(backslash).append(quote)", + " } else if (symbol == backslash) {", + " out.append(backslash).append(backslash)", + " } else if (symbol == 8.toChar()) {", + " out.append(backslash).append('b')", + " } else if (symbol == 9.toChar()) {", + " out.append(backslash).append('t')", + " } else if (symbol == 10.toChar()) {", + " out.append(backslash).append('n')", + " } else if (symbol == 12.toChar()) {", + " out.append(backslash).append('f')", + " } else if (symbol == 13.toChar()) {", + " out.append(backslash).append('r')", + " } else if (symbol < 32.toChar()) {", + ' val digits = "0123456789abcdef"', + " out.append(backslash).append('u').append('0').append('0')", + " out.append(digits[(symbol.code shr 4) and 15]).append(digits[symbol.code and 15])", + " } else {", + " out.append(symbol)", + " }", + " }", + " out.append(quote)", + " }", + "", + " private fun writeValue(out: StringBuilder, value: Any?) {", + " if (value == null) {", + ' out.append("null")', + " return", + " }", + " if (value is String) {", + " writeString(out, value)", + " return", + " }", + " if (value is Boolean) {", + ' out.append(if (value) "true" else "false")', + " return", + " }", + " if (value is Double || value is Float) {", + " val number = (value as Number).toDouble()", + " if (number.isNaN() || number.isInfinite()) {", + ' throw IllegalStateException("Non-finite number")', + " }", + " out.append(number.toString())", + " return", + " }", + " if (value is Number) {", + " out.append(value.toString())", + " return", + " }", + " if (value is Map<*, *>) {", + " out.append('{')", + " var first = true", + " for (entry in value.entries) {", + " if (!first) {", + " out.append(',')", + " }", + " first = false", + ' writeString(out, entry.key?.toString() ?: "null")', + " out.append(':')", + " writeValue(out, entry.value)", + " }", + " out.append('}')", + " return", + " }", + " if (value is List<*>) {", + " out.append('[')", + " var first = true", + " for (item in value) {", + " if (!first) {", + " out.append(',')", + " }", + " first = false", + " writeValue(out, item)", + " }", + " out.append(']')", + " return", + " }", + " writeString(out, value.toString())", + " }", + "", + " fun emit(hypothesisId: String, location: String, message: String, data: Any?) {", + " try {", + " val out = StringBuilder()", + " out.append('{')", + ' writeString(out, "hypothesisId")', + " out.append(':')", + " writeString(out, hypothesisId)", + " out.append(',')", + ' writeString(out, "location")', + " out.append(':')", + " writeString(out, location)", + " out.append(',')", + ' writeString(out, "message")', + " out.append(':')", + " writeString(out, message)", + " out.append(',')", + ' writeString(out, "data")', + " out.append(':')", + " writeValue(out, redact(data, java.util.IdentityHashMap(), 0))", + " out.append(',')", + ' writeString(out, "timestamp")', + " out.append(':')", + " out.append(System.currentTimeMillis())", + " out.append('}')", + " out.append(10.toChar())", + " val payload = out.toString().toByteArray(java.nio.charset.StandardCharsets.UTF_8)", + " if (payload.size > 65536) {", + " return", + " }", + ' val path = java.nio.file.Paths.get("__APPEND_PATH__")', + " try {", + ' java.nio.file.Files.createFile(path, java.nio.file.attribute.PosixFilePermissions.asFileAttribute(java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")))', + " } catch (nonPosix: UnsupportedOperationException) {", + " try {", + " java.nio.file.Files.createFile(path)", + " } catch (ignored: Throwable) {", + " }", + " } catch (ignored: Throwable) {", + " }", + " java.nio.file.Files.write(path, payload, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND)", + " } catch (failure: Throwable) {", + " // Observations must never change application behavior.", + " }", + " }", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "kotlin", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible Kotlin value that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/src/probes/render.ts b/src/probes/render.ts index 9208716..c622045 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -4,6 +4,7 @@ import { renderCSharpTemplate } from "./csharp"; import { renderGoTemplate } from "./go"; import { renderJavaTemplate } from "./java"; import { renderJavaScriptTemplate } from "./javascript"; +import { renderKotlinTemplate } from "./kotlin"; import { renderPhpTemplate } from "./php"; import { renderPowerShellTemplate } from "./powershell"; import { renderPythonTemplate } from "./python"; @@ -25,7 +26,8 @@ export type TemplateLanguage = | "rust" | "cpp" | "c" - | "java"; + | "java" + | "kotlin"; export type IngestMethod = "http" | "file"; @@ -96,6 +98,9 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { return "c"; case "java": return "java"; + case "kotlin": + case "kt": + return "kotlin"; default: return undefined; } @@ -136,6 +141,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderCTemplate(); case "java:file": return renderJavaTemplate(); + case "kotlin:file": + return renderKotlinTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 6ce91c7..e4f5b41 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -23,6 +23,7 @@ const supported = [ ["cpp", "file"], ["c", "file"], ["java", "file"], + ["kotlin", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -122,6 +123,8 @@ describe("session-independent template renderers", () => { expect(renderTemplate("c", "file").language).toBe("c"); expect(renderTemplate("Java", "FILE").language).toBe("java"); expect(renderTemplate("java", "file").language).toBe("java"); + expect(renderTemplate("Kotlin", "FILE").language).toBe("kotlin"); + expect(renderTemplate("kt", "file").language).toBe("kotlin"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -139,6 +142,7 @@ describe("session-independent template renderers", () => { ["cpp", "http"], ["c", "http"], ["java", "http"], + ["kotlin", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index 00b4e25..8504bdc 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -295,6 +295,28 @@ const fixtures: Fixture[] = [ sharedPrelude: 'java.util.Map __agentShared = new java.util.LinkedHashMap<>(); __agentShared.put("APIKey", "source-shared-secret");', }, + { + callData: + 'mapOf("value" to 42, "designToken" to "visible-design-token", "fortuneCookie" to "visible-fortune-cookie", "secretSauceName" to "visible-secret-sauce", "tokenCount" to 7, "passwordPolicy" to "visible-password-policy", "password" to "source-password-secret", "APIKey" to "source-api-acronym-secret", "APIToken" to "source-api-token-secret", "IDToken" to "source-id-token-secret", "OAuthToken" to "source-oauth-token-secret", "Client Secret" to "source-client-secret", "nested" to mapOf("apiKey" to "source-api-secret", "items" to listOf(mapOf("refresh-token" to "source-refresh-secret"), mapOf("credentials" to "source-credentials-secret"))))', + command: (path) => { + const kotlinc = Bun.which("kotlinc") ?? ""; + const java = Bun.which("java") ?? ""; + const jar = path.replace(/\.kt$/, ".jar"); + const script = `"${kotlinc}" -include-runtime -d "${jar}" "${path}" && "${java}" -jar "${jar}"`; + return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + }, + cycleData: "__agentCycle", + cyclePrelude: + 'val __agentCycle = java.util.LinkedHashMap(); __agentCycle.put("self", __agentCycle)', + file: "kotlin-file.kt", + ingest: "file", + language: "kotlin", + runtime: + Bun.which("kotlinc") !== null && Bun.which("java") !== null ? Bun.which("kotlinc") : null, + sharedData: 'mapOf("left" to __agentShared, "right" to __agentShared)', + sharedPrelude: + 'val __agentShared = java.util.LinkedHashMap(); __agentShared.put("APIKey", "source-shared-secret")', + }, ]; async function run(command: string[], env: Record = process.env) { @@ -584,7 +606,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); runtimeTest( @@ -614,7 +636,7 @@ describe("live language templates", () => { expect(executed.exitCode, executed.stderr).toBe(0); expect(`${executed.stdout}\n${executed.stderr}`).toContain("application-completed"); }, - 90_000, + 180_000, ); runtimeTest( @@ -669,7 +691,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); runtimeTest( @@ -720,7 +742,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); } @@ -859,6 +881,22 @@ describe("live language templates", () => { "}", ].join("\n"), }, + kotlin: { + dataExpression: "__agentValue", + prelude: [ + "val __agentValue = AgentCustomValue()", + '__agentValue.APIKey = "source-value-api-secret"', + '__agentValue.designToken = "visible-value-design-token"', + '__agentValue.Nested = java.util.Map.of("OAuthToken", "source-value-oauth-secret")', + ].join("\n"), + extraTypes: [ + "class AgentCustomValue {", + ' @JvmField var APIKey: String = ""', + ' @JvmField var Nested: Any = ""', + ' @JvmField var designToken: String = ""', + "}", + ].join("\n"), + }, powershell: { dataExpression: "$__agentValue", prelude: [ @@ -877,7 +915,7 @@ describe("live language templates", () => { }, }; - for (const language of ["csharp", "powershell", "java"]) { + for (const language of ["csharp", "powershell", "java", "kotlin"]) { const fixture = fixtures.find((candidate) => candidate.language === language); const unavailable = fixture?.runtime === null; const runtimeTest = unavailable && !requireRuntimes ? test.skip : test; @@ -939,7 +977,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); } @@ -985,7 +1023,7 @@ describe("live language templates", () => { expect(() => JSON.parse(line)).not.toThrow(); } }, - 90_000, + 180_000, ); } }); diff --git a/tests/fixtures/languages/kotlin-file.kt b/tests/fixtures/languages/kotlin-file.kt new file mode 100644 index 0000000..ad43917 --- /dev/null +++ b/tests/fixtures/languages/kotlin-file.kt @@ -0,0 +1,7 @@ +/* __EXTRA_TYPES__ */ +fun main() { + __CALL_TEMPLATE__ + println("application-completed") +} + +__HELPER_TEMPLATE__ From a686fa30b62c8369a8752627fc2f0978be911ec4 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 14:16:17 -0700 Subject: [PATCH 6/6] fix: address branch-review findings for the new language templates DESIGN.md advertised-list contradiction; C NULL-key guard on malloc failure; Rust allow attributes so user builds stay warning-free; C++ int constructor to remove the LL-suffix footgun. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 4 ++-- src/probes/c.ts | 3 +++ src/probes/cpp.ts | 1 + src/probes/rust.ts | 2 ++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ec590a2..f62dbe3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -121,8 +121,8 @@ The first supported combinations are: - Java + file - Kotlin + file -Every advertised combination must pass a live end-to-end test with its real runtime. Kotlin and -shell are not advertised until a safe serializer contract is defined. +Every advertised combination must pass a live end-to-end test with its real runtime. Shell is +not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/src/probes/c.ts b/src/probes/c.ts index 3199810..c745115 100644 --- a/src/probes/c.ts +++ b/src/probes/c.ts @@ -325,6 +325,9 @@ export function renderCTemplate(): ProbeTemplates { "}", "", "static int __agent_debug_is_secret_key(const char *key) {", + " if (key == NULL) {", + " return 0;", + " }", " char *normalized = __agent_debug_normalize_key(key);", " if (normalized == NULL) {", " return 0;", diff --git a/src/probes/cpp.ts b/src/probes/cpp.ts index 8921ad5..d3032b8 100644 --- a/src/probes/cpp.ts +++ b/src/probes/cpp.ts @@ -36,6 +36,7 @@ export function renderCppTemplate(): ProbeTemplates { "", " AgentValue() {}", " AgentValue(bool v) { kind = AgentKind::Bool; boolValue = v; }", + " AgentValue(int v) { kind = AgentKind::Int; intValue = v; }", " AgentValue(long long v) { kind = AgentKind::Int; intValue = v; }", " AgentValue(double v) { kind = AgentKind::Double; doubleValue = v; }", " AgentValue(const char* v) { kind = AgentKind::Str; strValue = v; }", diff --git a/src/probes/rust.ts b/src/probes/rust.ts index 3813750..72bf09a 100644 --- a/src/probes/rust.ts +++ b/src/probes/rust.ts @@ -10,6 +10,7 @@ export function renderRustTemplate(): ProbeTemplates { helperTemplate: [ "// #region agent log", "#[derive(Clone)]", + "#[allow(dead_code)]", "enum AgentValue {", " Null,", " Bool(bool),", @@ -236,6 +237,7 @@ export function renderRustTemplate(): ProbeTemplates { " }", "}", "", + "#[allow(non_snake_case)]", "fn __agentDebugEmit(hypothesis_id: &str, location: &str, message: &str, data: AgentValue) {", " let mut out = String::new();", " out.push('{');",