fsr-agentic-toolkit 0.1.0__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.
Files changed (30) hide show
  1. fsr_agentic_toolkit/__init__.py +10 -0
  2. fsr_agentic_toolkit/cli.py +130 -0
  3. fsr_agentic_toolkit/init_cmd.py +161 -0
  4. fsr_agentic_toolkit/reference/AUTHORING.md +987 -0
  5. fsr_agentic_toolkit/spec.py +73 -0
  6. fsr_agentic_toolkit/verbs/__init__.py +41 -0
  7. fsr_agentic_toolkit/verbs/archetypes/__init__.py +49 -0
  8. fsr_agentic_toolkit/verbs/archetypes/harvest.py +256 -0
  9. fsr_agentic_toolkit/verbs/archetypes/record.py +179 -0
  10. fsr_agentic_toolkit/verbs/archetypes/router.py +252 -0
  11. fsr_agentic_toolkit/verbs/archetypes/seed/.gitkeep +3 -0
  12. fsr_agentic_toolkit/verbs/archetypes/seed/reconcile-and-report.json +325 -0
  13. fsr_agentic_toolkit/verbs/archetypes/store.py +159 -0
  14. fsr_agentic_toolkit/verbs/mcp.py +284 -0
  15. fsr_agentic_toolkit/verbs/tools.py +2234 -0
  16. fsr_agentic_toolkit/workflow_tools.py +196 -0
  17. fsr_agentic_toolkit/workflows/__init__.py +27 -0
  18. fsr_agentic_toolkit/workflows/access.py +86 -0
  19. fsr_agentic_toolkit/workflows/build.py +57 -0
  20. fsr_agentic_toolkit/workflows/debug.py +121 -0
  21. fsr_agentic_toolkit/workflows/investigate.py +109 -0
  22. fsr_agentic_toolkit/workflows/orient.py +122 -0
  23. fsr_agentic_toolkit/workflows/provision.py +177 -0
  24. fsr_agentic_toolkit/workflows/repair.py +70 -0
  25. fsr_agentic_toolkit/workflows/result.py +243 -0
  26. fsr_agentic_toolkit/workflows/verify.py +102 -0
  27. fsr_agentic_toolkit-0.1.0.dist-info/METADATA +85 -0
  28. fsr_agentic_toolkit-0.1.0.dist-info/RECORD +30 -0
  29. fsr_agentic_toolkit-0.1.0.dist-info/WHEEL +4 -0
  30. fsr_agentic_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,10 @@
1
+ """FSR Agentic Toolkit: the agent layer for FortiSOAR.
2
+
3
+ pyfsr is the SDK. fsr_playbooks is the domain (compiler, validators, catalog).
4
+ This package is the agent layer on top of both: seven workflows that return one
5
+ evidence-backed result, the SDK verbs, one gate, one MCP server and one CLI. The
6
+ SOC Assistant connector is built on the same pieces.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.1.0"
@@ -0,0 +1,130 @@
1
+ """``fsr-agentic-toolkit``: the same tools as the MCP server, from a shell.
2
+
3
+ fsr-agentic-toolkit init --url fortisoar.example.com --api-key <key>
4
+ fsr-agentic-toolkit mcp # the MCP server (stdio)
5
+ fsr-agentic-toolkit tools # what the gate lets through
6
+ fsr-agentic-toolkit debug-run --playbook-or-id "Close Alert"
7
+ fsr-agentic-toolkit orient --modules alerts,heists --json
8
+ fsr-agentic-toolkit call search_records '{"module": "alerts", "limit": 5}'
9
+
10
+ Every workflow is a subcommand; its flags are the tool's arguments (objects and
11
+ lists as JSON, lists also comma-separated). Calls go through the one dispatch
12
+ and gate, and a workflow prints the same WorkflowResult the MCP tool returns
13
+ (text by default, ``--json`` for the tool envelope). The box comes from the
14
+ ``FSR_*`` environment or ``--instance`` (an alias in ``~/.pyfsr/instances.toml``).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from typing import Any
22
+
23
+ from .workflow_tools import WORKFLOW_SPECS
24
+
25
+
26
+ def _client(instance: str | None) -> Any:
27
+ from pyfsr.instances import InstanceRegistry
28
+
29
+ return InstanceRegistry.load().client(instance)
30
+
31
+
32
+ def _value(raw: str, schema: dict[str, Any]) -> Any:
33
+ kind = schema.get("type")
34
+ if kind in ("object", "array"):
35
+ try:
36
+ return json.loads(raw)
37
+ except json.JSONDecodeError:
38
+ if kind == "array":
39
+ return [p.strip() for p in raw.split(",") if p.strip()]
40
+ raise
41
+ if kind == "boolean":
42
+ return raw.strip().lower() in ("1", "true", "yes", "on")
43
+ if kind == "integer":
44
+ return int(raw)
45
+ if kind == "number":
46
+ return float(raw)
47
+ return raw
48
+
49
+
50
+ def _print_result(out: Any, as_json: bool) -> int:
51
+ from .workflows.result import WorkflowResult, render_text
52
+
53
+ if as_json or not isinstance(out, dict) or "workflow" not in out:
54
+ print(json.dumps(out, indent=2, default=str))
55
+ return 0 if not (isinstance(out, dict) and "error" in out) else 1
56
+ result = WorkflowResult.model_validate({k: v for k, v in out.items() if k != "ok"})
57
+ print(render_text(result))
58
+ return 0 if result.status != "blocked" else 1
59
+
60
+
61
+ def _add_workflow(sub: argparse._SubParsersAction, spec: Any) -> None:
62
+ name = spec.name.replace("_", "-")
63
+ p = sub.add_parser(name, help=spec.description.split(". ")[0] + ".")
64
+ props = spec.rendered_schema().get("properties") or {}
65
+ required = set(spec.input_schema.get("required") or [])
66
+ for arg, schema in props.items():
67
+ p.add_argument(f"--{arg.replace('_', '-')}", dest=arg, required=arg in required,
68
+ metavar=str(schema.get("type", "value")).upper(),
69
+ help=schema.get("description"))
70
+ p.add_argument("--instance", help="configured instance alias")
71
+ p.add_argument("--json", action="store_true", help="print the tool envelope as JSON")
72
+ p.set_defaults(func=_run_tool, tool=spec.name, schema_props=props)
73
+
74
+
75
+ def _run_tool(args: argparse.Namespace) -> int:
76
+ from .verbs.tools import dispatch
77
+
78
+ arguments = {k: _value(getattr(args, k), s) for k, s in args.schema_props.items()
79
+ if getattr(args, k, None) is not None}
80
+ return _print_result(dispatch(_client(args.instance), args.tool, arguments), args.json)
81
+
82
+
83
+ def _call(args: argparse.Namespace) -> int:
84
+ from .verbs.tools import dispatch
85
+
86
+ arguments = json.loads(args.arguments) if args.arguments else {}
87
+ return _print_result(dispatch(_client(args.instance), args.tool, arguments), args.json)
88
+
89
+
90
+ def _tools(args: argparse.Namespace) -> int:
91
+ from .verbs.tools import enabled_tools
92
+
93
+ for spec in enabled_tools():
94
+ print(f"{spec.name:32} tier {spec.tier} {spec.description.split('. ')[0]}")
95
+ return 0
96
+
97
+
98
+ def _mcp(args: argparse.Namespace) -> int:
99
+ from .verbs.mcp import main as serve
100
+
101
+ serve()
102
+ return 0
103
+
104
+
105
+ def build_parser() -> argparse.ArgumentParser:
106
+ from . import init_cmd
107
+
108
+ parser = argparse.ArgumentParser(prog="fsr-agentic-toolkit", description=__doc__.split("\n")[0])
109
+ sub = parser.add_subparsers(dest="command", required=True)
110
+ init_cmd.build_subparser(sub)
111
+ sub.add_parser("mcp", help="run the MCP server on stdio").set_defaults(func=_mcp)
112
+ sub.add_parser("tools", help="list the tools the gate allows, with their tier").set_defaults(func=_tools)
113
+ p_call = sub.add_parser("call", help="call any tool by name with JSON arguments")
114
+ p_call.add_argument("tool")
115
+ p_call.add_argument("arguments", nargs="?", default="")
116
+ p_call.add_argument("--instance")
117
+ p_call.add_argument("--json", action="store_true")
118
+ p_call.set_defaults(func=_call)
119
+ for spec in WORKFLOW_SPECS:
120
+ _add_workflow(sub, spec)
121
+ return parser
122
+
123
+
124
+ def main(argv: list[str] | None = None) -> int:
125
+ args = build_parser().parse_args(argv)
126
+ return int(args.func(args) or 0)
127
+
128
+
129
+ if __name__ == "__main__": # pragma: no cover
130
+ sys.exit(main())
@@ -0,0 +1,161 @@
1
+ """``fsr-agentic-toolkit init`` -- wire a coding agent to a FortiSOAR box.
2
+
3
+ ``fsr-agentic-toolkit init`` is the one-command bootstrap: verify the connection with a
4
+ real whoami call, optionally save the settings to an env file, and print the
5
+ MCP config snippet for the agent product in use (Claude Code, OpenCode, GitHub
6
+ Copilot). The snippets launch the toolkit's MCP server (``fsr-agentic-toolkit
7
+ mcp``), preferring the locally installed executable and falling back to
8
+ ``uvx --from fsr-agentic-toolkit``.
9
+
10
+ Safety env vars an agent setup may add to the snippet's ``env`` block:
11
+ ``FSR_MCP_READONLY=1``, ``FSR_MCP_ALLOW_TOOLS``, ``FSR_MCP_DENY_TOOLS`` (see
12
+ :mod:`fsr_agentic_toolkit.verbs.tools`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import shutil
21
+ import sys
22
+ from pathlib import Path
23
+
24
+
25
+ def _server_command() -> tuple[str, list[str]]:
26
+ """The command + args that launch the toolkit MCP server on this machine."""
27
+ exe = shutil.which("fsr-agentic-toolkit")
28
+ if exe:
29
+ return exe, ["mcp"]
30
+ return "uvx", ["--from", "fsr-agentic-toolkit", "fsr-agentic-toolkit", "mcp"]
31
+
32
+
33
+ def _env_block(args: argparse.Namespace) -> dict[str, str]:
34
+ env = {"FSR_BASE_URL": args.url, "FSR_API_KEY": args.api_key}
35
+ if args.no_verify_ssl:
36
+ env["FSR_VERIFY_SSL"] = "false"
37
+ env["FSR_SUPPRESS_INSECURE_WARNINGS"] = "true"
38
+ if args.readonly:
39
+ env["FSR_MCP_READONLY"] = "1"
40
+ return env
41
+
42
+
43
+ def _claude_code_snippet(args: argparse.Namespace) -> str:
44
+ command, cmd_args = _server_command()
45
+ cfg = {
46
+ "mcpServers": {
47
+ "fortisoar": {
48
+ "command": command,
49
+ **({"args": cmd_args} if cmd_args else {}),
50
+ "env": _env_block(args),
51
+ }
52
+ }
53
+ }
54
+ return "# .mcp.json (project root), or merge into ~/.claude.json\n" + json.dumps(cfg, indent=2)
55
+
56
+
57
+ def _opencode_snippet(args: argparse.Namespace) -> str:
58
+ command, cmd_args = _server_command()
59
+ cfg = {
60
+ "mcp": {
61
+ "fortisoar": {
62
+ "type": "local",
63
+ "command": [command, *cmd_args],
64
+ "environment": _env_block(args),
65
+ "enabled": True,
66
+ }
67
+ }
68
+ }
69
+ return "# opencode.json (project root) -- merge the 'mcp' key\n" + json.dumps(cfg, indent=2)
70
+
71
+
72
+ def _copilot_snippet(args: argparse.Namespace) -> str:
73
+ command, cmd_args = _server_command()
74
+ cfg = {
75
+ "servers": {
76
+ "fortisoar": {
77
+ "command": command,
78
+ **({"args": cmd_args} if cmd_args else {}),
79
+ "env": _env_block(args),
80
+ }
81
+ }
82
+ }
83
+ return "# .vscode/mcp.json (VS Code / GitHub Copilot agent mode)\n" + json.dumps(cfg, indent=2)
84
+
85
+
86
+ _SNIPPETS = {
87
+ "claude-code": _claude_code_snippet,
88
+ "opencode": _opencode_snippet,
89
+ "copilot": _copilot_snippet,
90
+ }
91
+
92
+
93
+ def _verify(args: argparse.Namespace) -> dict | None:
94
+ """whoami against the box; None (with a printed error) when unreachable."""
95
+ from pyfsr.client import FortiSOAR
96
+
97
+ try:
98
+ client = FortiSOAR(
99
+ args.url,
100
+ token=args.api_key,
101
+ verify_ssl=not args.no_verify_ssl,
102
+ suppress_insecure_warnings=args.no_verify_ssl,
103
+ )
104
+ return client.users.whoami()
105
+ except Exception as exc: # noqa: BLE001 - report, don't trace
106
+ print(f"error: could not verify the connection: {exc}", file=sys.stderr)
107
+ return None
108
+
109
+
110
+ def _write_env_file(path: Path, args: argparse.Namespace) -> None:
111
+ lines = [f"{k}={v}" for k, v in _env_block(args).items()]
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ # Create with 0600 from the first byte (no write-then-chmod exposure window).
114
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
115
+ with os.fdopen(fd, "w") as fh:
116
+ fh.write("\n".join(lines) + "\n")
117
+ os.chmod(path, 0o600) # an O_TRUNC of a pre-existing looser file keeps its old mode
118
+
119
+
120
+ def cmd_init(args: argparse.Namespace) -> int:
121
+ me = _verify(args)
122
+ if me is None:
123
+ if not args.skip_verify:
124
+ print("hint: re-run with --skip-verify to print the snippets anyway", file=sys.stderr)
125
+ return 1
126
+ print("continuing despite failed verification (--skip-verify)", file=sys.stderr)
127
+ else:
128
+ print(f"connected: {me.get('loginid')} <{me.get('email')}> @ {args.url}")
129
+
130
+ if args.save_env:
131
+ path = Path(args.save_env).expanduser()
132
+ _write_env_file(path, args)
133
+ print(f"saved credentials to {path} (mode 600)")
134
+
135
+ products = [p for p in _SNIPPETS if getattr(args, p.replace("-", "_"))]
136
+ if not products:
137
+ products = ["claude-code"]
138
+ for product in products:
139
+ print(f"\n--- {product} ---")
140
+ print(_SNIPPETS[product](args))
141
+ print(
142
+ "\nsafety: add FSR_MCP_READONLY=1 to the env block for a read-only surface, "
143
+ "or FSR_MCP_ALLOW_TOOLS/FSR_MCP_DENY_TOOLS to narrow it. Destructive tools "
144
+ "always need confirm=true."
145
+ )
146
+ return 0
147
+
148
+
149
+ def build_subparser(sub: argparse._SubParsersAction) -> None:
150
+ """Wire the ``agent`` subcommands onto an existing subparsers object."""
151
+ p_init = sub.add_parser("init", help="verify a box and print the MCP setup snippet for your coding agent")
152
+ p_init.add_argument("--url", required=True, help="FortiSOAR base URL, e.g. fortisoar.example.com")
153
+ p_init.add_argument("--api-key", required=True, help="API key (Settings > API Keys on the appliance)")
154
+ p_init.add_argument("--no-verify-ssl", action="store_true", help="disable TLS verification (lab boxes)")
155
+ p_init.add_argument("--readonly", action="store_true", help="bake FSR_MCP_READONLY=1 into the snippet")
156
+ p_init.add_argument("--save-env", metavar="PATH", help="also write the FSR_* values to this env file (mode 600)")
157
+ p_init.add_argument("--skip-verify", action="store_true", help="print snippets even if whoami fails")
158
+ p_init.add_argument("--claude-code", action="store_true", help="print the Claude Code .mcp.json snippet (default)")
159
+ p_init.add_argument("--opencode", action="store_true", help="print the OpenCode opencode.json snippet")
160
+ p_init.add_argument("--copilot", action="store_true", help="print the VS Code / Copilot .vscode/mcp.json snippet")
161
+ p_init.set_defaults(func=cmd_init)