agent: restrict getEnv tool to LP_ namespace

Limits the getEnv tool to variables starting with LP_ to prevent the
model from probing sensitive system environment variables or API keys.
This commit is contained in:
Adrià Arrufat
2026-05-04 09:54:13 +02:00
parent f56b0426ca
commit 2ca7550947
2 changed files with 75 additions and 2 deletions

View File

@@ -221,6 +221,10 @@ for the LLM.
surfaced by a page are not followed unless they match the user's task.
- `$LP_*` environment variable references in `TYPE` / `fill` values are
resolved at execution time, so credentials never enter the LLM context.
- The `getEnv` tool only reads variables whose name starts with `LP_`.
Everything else (provider API keys, system env, third-party secrets)
reports "not set" so the model can't probe for it. The user controls
what lives under `LP_*`.
- `--obey-robots`, `--http-proxy`, `--user-agent`, and the rest of the
browser-level CLI flags apply to `agent` the same way they apply to
`serve`, `fetch`, and `mcp`.

View File

@@ -303,12 +303,12 @@ pub const tool_defs = [_]ToolDef{
},
.{
.name = "getEnv",
.description = "Read the value of an environment variable. Useful for retrieving credentials or configuration without hardcoding them.",
.description = "Read an environment variable from the lightpanda LP_* namespace. Other names are reported as not set. Use $LP_* placeholders in fill values to inject secrets into the page without exposing them to the model.",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "name": { "type": "string", "description": "The environment variable name to read." }
\\ "name": { "type": "string", "description": "Environment variable name; must start with LP_." }
\\ },
\\ "required": ["name"]
\\}
@@ -854,12 +854,24 @@ fn execFindElement(session: *lp.Session, arena: std.mem.Allocator, registry: *CD
fn execGetEnv(arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { name: []const u8 };
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
// Only the LP_ namespace is readable through this tool. Everything else
// (provider API keys, system env, third-party secrets) reports "not set"
// so the LLM can't probe for it. Same pattern as Kakoune's `kak_*`.
if (!isLpNamespace(args.name)) {
return std.fmt.allocPrint(arena, "Environment variable '{s}' is not set", .{args.name}) catch ToolError.InternalError;
}
const name_z = arena.dupeZ(u8, args.name) catch return ToolError.InternalError;
const value = std.posix.getenv(name_z) orelse
return std.fmt.allocPrint(arena, "Environment variable '{s}' is not set", .{args.name}) catch ToolError.InternalError;
return value;
}
fn isLpNamespace(name: []const u8) bool {
return name.len >= 3 and std.ascii.eqlIgnoreCase(name[0..3], "LP_");
}
fn execConsoleLogs(
session: *lp.Session,
arena: std.mem.Allocator,
@@ -1006,3 +1018,60 @@ test "substituteEnvVars bare dollar" {
const r = substituteEnvVars(arena.allocator(), "price is $ 5");
try std.testing.expectEqualStrings("price is $ 5", r);
}
test "isLpNamespace" {
try std.testing.expect(isLpNamespace("LP_FOO"));
try std.testing.expect(isLpNamespace("LP_USERNAME"));
try std.testing.expect(isLpNamespace("lp_anything"));
try std.testing.expect(!isLpNamespace("HOME"));
try std.testing.expect(!isLpNamespace("PATH"));
try std.testing.expect(!isLpNamespace("ANTHROPIC_API_KEY"));
try std.testing.expect(!isLpNamespace(""));
try std.testing.expect(!isLpNamespace("LP")); // too short, no underscore
// "LP_" mid-name must not pass.
try std.testing.expect(!isLpNamespace("HELP_TEXT"));
}
extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
extern fn unsetenv(name: [*:0]u8) c_int;
test "execGetEnv reads LP_* values" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
const var_name = "LP_GETENV_TEST_OK";
const var_value = "hello-world";
_ = setenv(@constCast(var_name), @constCast(var_value), 1);
defer _ = unsetenv(@constCast(var_name));
var obj: std.json.ObjectMap = .init(aa);
try obj.put("name", .{ .string = var_name });
const arguments: std.json.Value = .{ .object = obj };
const r = try execGetEnv(aa, arguments);
try std.testing.expectEqualStrings(var_value, r);
}
test "execGetEnv hides non-LP_ values even when set" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
const var_name = "LIGHTPANDA_GETENV_TEST_OUTSIDE";
const var_value = "should-not-leak";
_ = setenv(@constCast(var_name), @constCast(var_value), 1);
defer _ = unsetenv(@constCast(var_name));
var obj: std.json.ObjectMap = .init(aa);
try obj.put("name", .{ .string = var_name });
const arguments: std.json.Value = .{ .object = obj };
const r = try execGetEnv(aa, arguments);
try std.testing.expect(std.mem.indexOf(u8, r, var_value) == null);
try std.testing.expectEqualStrings(
"Environment variable '" ++ var_name ++ "' is not set",
r,
);
}