agent-nook 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_nook/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ # Agent Nook
2
+ """A lightweight bwrap sandbox for AI agents.
3
+
4
+ Usage:
5
+ from agent_nook.sandbox import BwrapSandbox, BwrapBuilder, SandboxConfig
6
+
7
+ # Preferred: use BwrapSandbox.run() which handles execution
8
+ result = BwrapSandbox(config).run(["python3", "agent.py"])
9
+
10
+ # Manual builder
11
+ cmd = BwrapBuilder(config).build(["python3", "agent.py"])
12
+ """
13
+
14
+ # Single source of truth for the version. pyproject.toml reads this
15
+ # dynamically via [tool.hatch.version]; __main__.py imports it.
16
+ __version__ = "0.1.2"
17
+
18
+ # Unified sandbox exports
19
+ # Config exports
20
+ from agent_nook.config.loader import ConfigLoader
21
+ from agent_nook.config.loader import ConfigValidationError
22
+ from agent_nook.sandbox import BwrapBuilder
23
+ from agent_nook.sandbox import BwrapSandbox
24
+ from agent_nook.sandbox import SandboxConfig
25
+ from agent_nook.sandbox import SandboxResult
26
+
27
+
28
+ __all__ = [
29
+ "BwrapBuilder",
30
+ "BwrapSandbox",
31
+ "ConfigLoader",
32
+ "ConfigValidationError",
33
+ "SandboxConfig",
34
+ "SandboxResult",
35
+ ]
agent_nook/__main__.py ADDED
@@ -0,0 +1,356 @@
1
+ """Agent Nook CLI — Main entry point.
2
+
3
+ Provides a command-line interface for creating and running sandboxed agents.
4
+
5
+ Usage:
6
+ agent-nook run python3 agent.py arg1 arg2
7
+ agent-nook run --chdir /app echo hello
8
+ agent-nook run -- /bin/ls /tmp
9
+ agent-nook run -- /usr/bin/python3 -c "print(42)"
10
+ agent-nook status # Shows status of running sandboxes
11
+ agent-nook logs # Shows recent log output
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import logging
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from agent_nook import __version__
24
+
25
+
26
+ def _auto_init() -> None:
27
+ """Automatically initialize configuration and directories on first run.
28
+
29
+ This ensures that the required directories exist before any command is run:
30
+ - ~/.config/agent-nook/ (config)
31
+ - ~/.local/state/agent-nook/ (state, logs)
32
+
33
+ It also copies the bundled default config file if it doesn't exist.
34
+ """
35
+ from agent_nook.config import copy_bundled_config
36
+ from agent_nook.config import get_config_path
37
+ from agent_nook.utils.directories import ensure_directories
38
+ from agent_nook.utils.logger import get_log_directory
39
+
40
+ logger = logging.getLogger("agent_nook")
41
+
42
+ # Ensure directories exist using XDG-compliant paths
43
+ # This respects XDG_CONFIG_HOME and XDG_STATE_HOME environment variables
44
+ config_dir = Path(get_config_path()).parent
45
+ xdg_state_logs = Path(get_log_directory())
46
+
47
+ ensure_directories([config_dir, xdg_state_logs])
48
+ logger.debug("Ensured directories exist")
49
+
50
+ # Copy bundled config if user config doesn't exist
51
+ bundled_config_path = Path(__file__).parent / "config" / "sandbox.yaml"
52
+ if bundled_config_path.exists():
53
+ if not Path(get_config_path()).exists():
54
+ copy_bundled_config(bundled_config_path)
55
+ logger.debug("Copied bundled config to user config dir")
56
+
57
+
58
+ def _setup_logging(verbose: bool) -> None:
59
+ """Initialize logging at startup.
60
+
61
+ This must be called early in the application lifecycle,
62
+ before importing modules that use the logger.
63
+ """
64
+ from agent_nook.utils.logger import main_logger
65
+
66
+ logger = main_logger("agent_nook", level="DEBUG" if verbose else "INFO")
67
+ logger.debug("Logger initialized")
68
+
69
+
70
+ def main() -> int:
71
+ """Main entry point."""
72
+ parser = argparse.ArgumentParser(
73
+ prog="agent-nook",
74
+ description="A lightweight bwrap sandbox for AI agents",
75
+ )
76
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
77
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
78
+ parser.add_argument("--quiet", action="store_true", help="Suppress non-error output")
79
+
80
+ subparsers = parser.add_subparsers(dest="subcommand", help="Available commands")
81
+
82
+ # run command
83
+ run_parser = subparsers.add_parser("run", help="Run a command in a sandbox")
84
+ # Use REMAINDER so that -c, --foo, etc. inside the command are not parsed
85
+ # as agent-nook options. Pass everything after first arg through to the sandbox.
86
+ run_parser.add_argument(
87
+ "command",
88
+ nargs=argparse.REMAINDER,
89
+ metavar="CMD [ARGS...]",
90
+ help="Command to execute in the sandbox (e.g. 'python3 agent.py')",
91
+ )
92
+ run_parser.add_argument(
93
+ "--config",
94
+ default=None,
95
+ help="Override config file path (default: ~/.config/agent-nook/sandbox.yaml)",
96
+ )
97
+ run_parser.add_argument("--chdir", metavar="DIR", help="Change working directory in sandbox (overrides config)")
98
+ run_parser.add_argument(
99
+ "--cap-add",
100
+ action="append",
101
+ default=[],
102
+ metavar="CAP",
103
+ help="Add capability (e.g. 'CAP_NET_BIND_SERVICE', repeat for multiple)",
104
+ )
105
+ run_parser.add_argument(
106
+ "--cap-drop", action="append", default=[], metavar="CAP", help="Drop capability (e.g. 'CAP_SYS_ADMIN', repeat for multiple)"
107
+ )
108
+ run_parser.add_argument(
109
+ "--unshare", action="append", default=[], metavar="NS", help="Unshare namespace (e.g. 'pid,uts', repeat for multiple)"
110
+ )
111
+ run_parser.add_argument(
112
+ "--bind", action="append", default=[], metavar="SRC:DEST", help="Bind mount host SRC to sandbox DEST (repeat for multiple)"
113
+ )
114
+ run_parser.add_argument(
115
+ "--ro-bind",
116
+ action="append",
117
+ default=[],
118
+ metavar="SRC:DEST",
119
+ help="Read-only bind mount host SRC to sandbox DEST (repeat for multiple)",
120
+ )
121
+ run_parser.add_argument(
122
+ "--env",
123
+ action="append",
124
+ default=[],
125
+ metavar="KEY=VALUE",
126
+ help="Set environment variable (e.g. 'KEY=value', repeat for multiple)",
127
+ )
128
+ run_parser.add_argument(
129
+ "--unset-env", action="append", default=[], metavar="KEY", help="Unset environment variable (repeat for multiple)"
130
+ )
131
+ run_parser.add_argument("--hostname", metavar="HOSTNAME", help="Set sandbox hostname")
132
+ run_parser.add_argument(
133
+ "--new-session", action="store_true", default=True, help="Create new session (prevents TIOCSTI attacks, default: on)"
134
+ )
135
+ run_parser.add_argument(
136
+ "--no-new-session", action="store_false", dest="new_session", help="Don't create new session (allows TIOCSTI)"
137
+ )
138
+ run_parser.add_argument("--caps", action="append", default=[], help="Additional capabilities to add (repeat for multiple)")
139
+ run_parser.add_argument("--drop-caps", action="append", default=[], help="Additional capabilities to drop (repeat for multiple)")
140
+ run_parser.add_argument(
141
+ "--die-with-parent", action="store_true", default=True, help="Kill sandbox child when parent dies (default: on)"
142
+ )
143
+ run_parser.add_argument(
144
+ "--no-die-with-parent", action="store_false", dest="die_with_parent", help="Keep sandbox alive after parent exits"
145
+ )
146
+
147
+ # status command
148
+ status_parser = subparsers.add_parser("status", help="Show sandbox status")
149
+ status_parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed information")
150
+
151
+ # logs command
152
+ logs_parser = subparsers.add_parser("logs", help="Show recent logs")
153
+ logs_parser.add_argument("--tail", "-n", type=int, default=50, help="Number of lines to show (default: 50)")
154
+ logs_parser.add_argument("-f", "-F", "--follow", action="store_true", help="Follow log output (like tail -F)")
155
+
156
+ # list command
157
+ list_parser = subparsers.add_parser("list", help="List available sandboxes")
158
+ list_parser.add_argument("--verbose", "-v", action="store_true", help="Show details")
159
+
160
+ args = parser.parse_args()
161
+
162
+ if args.subcommand is None:
163
+ parser.print_help()
164
+ return 0
165
+
166
+ if args.verbose:
167
+ os.environ["PYTHONVERBOSE"] = "1"
168
+
169
+ # Initialize logging early, before any modules are imported
170
+ _setup_logging(args.verbose)
171
+
172
+ # Auto-initialize configuration and directories
173
+ _auto_init()
174
+
175
+ try:
176
+ return _dispatch_command(args)
177
+ except KeyboardInterrupt:
178
+ print("\nInterrupted.")
179
+ return 130
180
+ except SystemExit as e:
181
+ return e.code if e.code is not None else 0
182
+ except Exception as e:
183
+ print(f"Error: {e}", file=sys.stderr)
184
+ return 1
185
+
186
+
187
+ def _dispatch_command(args: argparse.Namespace) -> int:
188
+ """Dispatch to the appropriate command handler."""
189
+ subcommand = args.subcommand if hasattr(args, "subcommand") else args.command
190
+ if subcommand == "run":
191
+ return _cmd_run(args)
192
+ elif subcommand == "status":
193
+ return _cmd_status(args)
194
+ elif subcommand == "logs":
195
+ return _cmd_logs(args)
196
+ elif subcommand == "list":
197
+ return _cmd_list(args)
198
+ else:
199
+ print(f"Unknown command: {subcommand}", file=sys.stderr)
200
+ return 1
201
+
202
+
203
+ def _cmd_run(args: argparse.Namespace) -> int:
204
+ """Run a command inside a sandbox."""
205
+ from agent_nook.config.loader import ConfigLoader
206
+ from agent_nook.config.loader import ConfigValidationError
207
+ from agent_nook.sandbox import BwrapSandbox
208
+
209
+ # Logging is already initialized by _setup_logging()
210
+ logger = logging.getLogger("agent_nook")
211
+ logger.info("Agent Nook v%s — Running command", __version__)
212
+
213
+ # Unified config loading: handles file resolution, validation, overrides, and construction
214
+ try:
215
+ config_loader = ConfigLoader(None) # Let load_with_overrides resolve the path
216
+ config = config_loader.load_with_overrides(args)
217
+ from agent_nook.utils.logger import set_sandbox_name
218
+
219
+ set_sandbox_name(config.name)
220
+ except FileNotFoundError as e:
221
+ logger.error("Config file not found: %s", e)
222
+ return 1
223
+ except ConfigValidationError as e:
224
+ logger.error("Configuration validation failed: %s", e)
225
+ return 1
226
+ except RuntimeError as e:
227
+ logger.error("Failed to load config: %s", e)
228
+ return 1
229
+
230
+ # Build the command from positional args
231
+ command: list[str] = args.command if args.command else []
232
+ if not command:
233
+ print("error: no command specified. Use 'agent-nook run <command>'", file=sys.stderr)
234
+ return 1
235
+
236
+ logger.debug("Executing: %s", " ".join(command))
237
+
238
+ # Run the command in the sandbox using the unified API
239
+ try:
240
+ result = BwrapSandbox(config).run(command)
241
+ if result.success:
242
+ logger.info("✓ Sandbox executed successfully")
243
+ return 0
244
+ else:
245
+ logger.error("✗ Sandbox execution failed with return code %d", result.return_code)
246
+ return result.return_code or 1
247
+ except RuntimeError as e:
248
+ logger.error("Bubblewrap error: %s", e)
249
+ return 1
250
+ except subprocess.TimeoutExpired:
251
+ logger.error("Sandbox execution timed out after %s seconds", config.timeout)
252
+ return 1
253
+ except Exception:
254
+ logger.exception("Unexpected error")
255
+ return 1
256
+
257
+
258
+ def _cmd_status(args: argparse.Namespace) -> int:
259
+ """Display agent-nook status."""
260
+ from agent_nook.config import get_config_path
261
+ from agent_nook.utils.logger import get_log_file_path
262
+ from agent_nook.utils.logger import get_state_dir
263
+
264
+ logger = logging.getLogger("agent_nook")
265
+ logger.info("Agent Nook Status")
266
+ logger.info("=" * 40)
267
+
268
+ try:
269
+ result = subprocess.run(["bwrap", "--version"], capture_output=True, text=True, timeout=5, check=False)
270
+ if result.returncode == 0:
271
+ logger.info(" Bubblewrap version: %s", result.stdout.strip())
272
+ else:
273
+ logger.warning(" Bubblewrap not found on PATH")
274
+ except FileNotFoundError:
275
+ logger.warning(" Bubblewrap not found")
276
+
277
+ config_path = get_config_path()
278
+
279
+ if os.path.exists(config_path):
280
+ logger.info(" Config: %s (found)", config_path)
281
+ else:
282
+ logger.info(" Config: %s (not found)", config_path)
283
+
284
+ state_path = get_state_dir()
285
+
286
+ if os.path.exists(state_path):
287
+ logger.info(" State: %s (found)", state_path)
288
+ log_path = get_log_file_path()
289
+ if os.path.exists(log_path):
290
+ logger.info(" Logs: %s (%d KB)", log_path, os.path.getsize(log_path) / 1024)
291
+ else:
292
+ logger.info(" State: %s (not found)", state_path)
293
+
294
+ logger.info("")
295
+ logger.info("Commands:")
296
+ logger.info(" agent-nook run python3 agent.py")
297
+ logger.info(" agent-nook logs")
298
+ logger.info(" agent-nook list")
299
+
300
+ return 0
301
+
302
+
303
+ def _cmd_logs(args: argparse.Namespace) -> int:
304
+ """Show recent logs, optionally following them live (like tail -F)."""
305
+ from agent_nook.utils.logger import get_log_file_path
306
+
307
+ logger = logging.getLogger("agent_nook")
308
+
309
+ log_file = get_log_file_path()
310
+
311
+ if args.follow:
312
+ # tail -F (--follow=name --retry) reopens the file after log rotation
313
+ # and waits if it does not exist yet, so this can be started before
314
+ # any sandbox has run.
315
+ try:
316
+ proc = subprocess.Popen(["tail", "-n", str(args.tail), "-F", log_file])
317
+ except FileNotFoundError:
318
+ logger.error("tail command not found; cannot follow logs")
319
+ return 1
320
+ try:
321
+ returncode = proc.wait()
322
+ except KeyboardInterrupt:
323
+ # In a terminal, Ctrl+C reaches both processes; the kill covers
324
+ # the case where only this process received the signal.
325
+ proc.kill()
326
+ proc.wait()
327
+ raise # main() converts this to exit code 130
328
+ return returncode or 0
329
+
330
+ if not os.path.exists(log_file):
331
+ logger.info("No log file found yet.")
332
+ return 0
333
+
334
+ try:
335
+ result = subprocess.run(["tail", "-n", str(args.tail), log_file], check=False)
336
+ except FileNotFoundError:
337
+ logger.error("tail command not found; cannot show logs")
338
+ return 1
339
+ return result.returncode or 0
340
+
341
+
342
+ def _cmd_list(args: argparse.Namespace) -> int:
343
+ """List available sandboxes (placeholder)."""
344
+ logger = logging.getLogger("agent_nook")
345
+ logger.info("Agent Nook — Available Sandboxes")
346
+ logger.info("=" * 40)
347
+ logger.info("No custom sandboxes configured.")
348
+ logger.info("")
349
+ logger.info("Create a sandbox by editing ~/.config/agent-nook/sandbox.yaml")
350
+ logger.info("or by adding entries to the --mounts option.")
351
+
352
+ return 0
353
+
354
+
355
+ if __name__ == "__main__":
356
+ sys.exit(main())
@@ -0,0 +1,40 @@
1
+ """Configuration module for Agent Nook.
2
+
3
+ Provides XDG-compliant path resolution and configuration management.
4
+
5
+ XDG directories:
6
+ ~/.config/agent-nook/ (configuration)
7
+ ~/.local/state/agent-nook/ (state, logs)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ from agent_nook.config.loader import ConfigLoader
16
+ from agent_nook.config.loader import ConfigValidationError
17
+ from agent_nook.config.loader import get_config_path
18
+
19
+
20
+ def copy_bundled_config(source_path: str | Path) -> None:
21
+ """Copy a bundled configuration file to the user's config directory.
22
+
23
+ Args:
24
+ source_path: Path to the bundled configuration file.
25
+
26
+ Example:
27
+ >>> copy_bundled_config("/path/to/bundled/sandbox.yaml")
28
+ """
29
+ target_path = Path(source_path)
30
+ dest_path = Path(get_config_path())
31
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
32
+ shutil.copy2(target_path, dest_path)
33
+
34
+
35
+ __all__ = [
36
+ "ConfigLoader",
37
+ "ConfigValidationError",
38
+ "copy_bundled_config",
39
+ "get_config_path",
40
+ ]