Merge branch 'main' into fix/editproperly
22 files · +2005 -52
M README.md +13 -0
| @@ -26,6 +26,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 26 | 26 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | |
| 27 | 27 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | |
| 28 | 28 | - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md). | |
| 29 | + | - portable agents from local directories, `.zot` files, or temporary public GitHub downloads; see [docs/zotfiles.md](docs/zotfiles.md). | |
| 29 | 30 | ||
| 30 | 31 | ## Install | |
| 31 | 32 | ||
| @@ -211,6 +212,18 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se | |||
| 211 | 212 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | |
| 212 | 213 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | |
| 213 | 214 | ||
| 215 | + | ## zotfile agents | |
| 216 | + | ||
| 217 | + | A zotfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zot` artifact, or directly from a public GitHub repository: | |
| 218 | + | ||
| 219 | + | ```bash | |
| 220 | + | zot run ./my-agent | |
| 221 | + | zot run ./my-agent.zot | |
| 222 | + | zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot | |
| 223 | + | ``` | |
| 224 | + | ||
| 225 | + | For a GitHub URL, zot downloads the repository archive into a temporary directory, validates and runs the selected agent, then removes the downloaded source when the command exits. Agent data, consent receipts, and sessions still persist under `$ZOT_HOME`. See [docs/zotfiles.md](docs/zotfiles.md) for authoring, permissions, packaging, and current limitations. | |
| 226 | + | ||
| 214 | 227 | ## Embedding | |
| 215 | 228 | ||
| 216 | 229 | Two ways to drive zot from another program: | |
| @@ -26,6 +26,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 26 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | 26 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | ||
| 27 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | 27 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | ||
| 28 | - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md). | 28 | - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md). | ||
| 29 | + | - portable agents from local directories, `.zot` files, or temporary public GitHub downloads; see [docs/zotfiles.md](docs/zotfiles.md). | |||
| 29 | 30 | ||||
| 30 | ## Install | 31 | ## Install | ||
| 31 | 32 | ||||
| @@ -211,6 +212,18 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se | |||||
| 211 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | 212 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | ||
| 212 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | 213 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | ||
| 213 | 214 | ||||
| 215 | + | ## zotfile agents | |||
| 216 | + | ||||
| 217 | + | A zotfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zot` artifact, or directly from a public GitHub repository: | |||
| 218 | + | ||||
| 219 | + | ```bash | |||
| 220 | + | zot run ./my-agent | |||
| 221 | + | zot run ./my-agent.zot | |||
| 222 | + | zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot | |||
| 223 | + | ``` | |||
| 224 | + | ||||
| 225 | + | For a GitHub URL, zot downloads the repository archive into a temporary directory, validates and runs the selected agent, then removes the downloaded source when the command exits. Agent data, consent receipts, and sessions still persist under `$ZOT_HOME`. See [docs/zotfiles.md](docs/zotfiles.md) for authoring, permissions, packaging, and current limitations. | |||
| 226 | + | ||||
| 214 | ## Embedding | 227 | ## Embedding | ||
| 215 | 228 | ||||
| 216 | Two ways to drive zot from another program: | 229 | Two ways to drive zot from another program: | ||
A docs/zotfiles.md +607 -0
| @@ -0,0 +1,607 @@ | |||
| 1 | + | # Zotfile agents | |
| 2 | + | ||
| 3 | + | A Zotfile packages an agent's behavior into one portable `.zot` file. It can contain the agent's instructions, reusable skills, static assets, and metadata describing the runtime, model, operating-system, binary, and tool permissions it needs. | |
| 4 | + | ||
| 5 | + | The current implementation supports creating, inspecting, verifying, and running local directories and `.zot` archives. It can also run an agent directory directly from a public GitHub repository without keeping a clone. Registry distribution, installation, signatures, bundled executable extensions, network permissions, and environment permissions are not implemented yet. | |
| 6 | + | ||
| 7 | + | ## Quick start | |
| 8 | + | ||
| 9 | + | Create a directory for the agent: | |
| 10 | + | ||
| 11 | + | ```text | |
| 12 | + | reviewer/ | |
| 13 | + | ├── manifest.json | |
| 14 | + | ├── AGENT.md | |
| 15 | + | └── skills/ | |
| 16 | + | └── code-review/SKILL.md | |
| 17 | + | ``` | |
| 18 | + | ||
| 19 | + | Add a minimal `manifest.json`: | |
| 20 | + | ||
| 21 | + | ```json | |
| 22 | + | { | |
| 23 | + | "zotfile": 1, | |
| 24 | + | "name": "code-reviewer", | |
| 25 | + | "version": "0.1.0", | |
| 26 | + | "description": "Reviews a repository and reports actionable findings.", | |
| 27 | + | "runtime": { | |
| 28 | + | "min_zot": "0.2.76" | |
| 29 | + | }, | |
| 30 | + | "model": { | |
| 31 | + | "requires": ["tools"], | |
| 32 | + | "min_context": 64000 | |
| 33 | + | }, | |
| 34 | + | "permissions": { | |
| 35 | + | "fs": { | |
| 36 | + | "read": ["${workspace}"], | |
| 37 | + | "write": [] | |
| 38 | + | }, | |
| 39 | + | "bash": { | |
| 40 | + | "mode": "none" | |
| 41 | + | } | |
| 42 | + | }, | |
| 43 | + | "requirements": { | |
| 44 | + | "os": ["darwin", "linux", "windows"] | |
| 45 | + | }, | |
| 46 | + | "entry": { | |
| 47 | + | "greeting": "What should I review?", | |
| 48 | + | "default_prompt": null | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ``` | |
| 52 | + | ||
| 53 | + | Add the agent's standing instructions to `AGENT.md`: | |
| 54 | + | ||
| 55 | + | ```markdown | |
| 56 | + | # Code reviewer | |
| 57 | + | ||
| 58 | + | Review the current repository without modifying it. | |
| 59 | + | ||
| 60 | + | Prioritize correctness, security, regressions, and missing tests. Report findings | |
| 61 | + | in severity order with file and line references. Do not praise the code or | |
| 62 | + | summarize files that have no findings. | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | Test the directory directly during development: | |
| 66 | + | ||
| 67 | + | ```bash | |
| 68 | + | zot inspect ./reviewer | |
| 69 | + | zot run ./reviewer | |
| 70 | + | zot run ./reviewer "Review the authentication package" | |
| 71 | + | ``` | |
| 72 | + | ||
| 73 | + | Package it when ready: | |
| 74 | + | ||
| 75 | + | ```bash | |
| 76 | + | zot pack ./reviewer | |
| 77 | + | # wrote code-reviewer.zot | |
| 78 | + | # digest sha256:... | |
| 79 | + | ||
| 80 | + | zot verify ./code-reviewer.zot | |
| 81 | + | zot run ./code-reviewer.zot | |
| 82 | + | ``` | |
| 83 | + | ||
| 84 | + | The first run displays the declared permissions and asks for consent before the agent starts. | |
| 85 | + | ||
| 86 | + | ## Directory layout | |
| 87 | + | ||
| 88 | + | ```text | |
| 89 | + | my-agent/ | |
| 90 | + | ├── manifest.json # identity, requirements, and permissions, required | |
| 91 | + | ├── AGENT.md # persona and standing instructions, required | |
| 92 | + | ├── skills/ # SKILL.md directories, optional | |
| 93 | + | │ └── my-skill/SKILL.md | |
| 94 | + | ├── assets/ # static files, optional | |
| 95 | + | └── README.md # human-facing documentation, optional | |
| 96 | + | ``` | |
| 97 | + | ||
| 98 | + | Only `manifest.json` and a regular `AGENT.md` file are required. Other ordinary files and directories are included in the archive, but the runtime gives no special behavior to `assets/` or `README.md` yet. Files in `assets/` are not automatically added to the model context or granted filesystem access. | |
| 99 | + | ||
| 100 | + | Do not include executable extensions in `extensions/`. The local runtime rejects any bundled extension directory containing `extension.json` because extension subprocesses cannot yet be confined to the manifest permissions. This is a deliberate fail-closed restriction. | |
| 101 | + | ||
| 102 | + | Symlinks are not supported and cause `zot pack` to fail. | |
| 103 | + | ||
| 104 | + | ## `AGENT.md` | |
| 105 | + | ||
| 106 | + | By default, `AGENT.md` is appended to zot's system prompt. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. | |
| 107 | + | ||
| 108 | + | Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose. | |
| 109 | + | ||
| 110 | + | Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement system prompt: | |
| 111 | + | ||
| 112 | + | ```json | |
| 113 | + | { | |
| 114 | + | "replace_system_prompt": true | |
| 115 | + | } | |
| 116 | + | ``` | |
| 117 | + | ||
| 118 | + | Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. | |
| 119 | + | ||
| 120 | + | ## Bundled skills | |
| 121 | + | ||
| 122 | + | Place skills under `skills/<name>/SKILL.md` using the normal zot skill format: | |
| 123 | + | ||
| 124 | + | ```text | |
| 125 | + | skills/ | |
| 126 | + | └── investigate-failure/ | |
| 127 | + | └── SKILL.md | |
| 128 | + | ``` | |
| 129 | + | ||
| 130 | + | ```markdown | |
| 131 | + | --- | |
| 132 | + | name: investigate-failure | |
| 133 | + | description: Diagnose a failing command from its output and relevant source files. | |
| 134 | + | --- | |
| 135 | + | ||
| 136 | + | # Investigate failure | |
| 137 | + | ||
| 138 | + | 1. Reproduce the failure if permissions allow it. | |
| 139 | + | 2. Identify the first meaningful error rather than downstream noise. | |
| 140 | + | 3. Read the implementation and nearest tests. | |
| 141 | + | 4. Propose the smallest correction and validation plan. | |
| 142 | + | ``` | |
| 143 | + | ||
| 144 | + | Bundled skills are added to normal skill discovery while the Zotfile is running. The model sees their name and description in the skill manifest and can load the full body through the `skill` tool. See [skills.md](skills.md) for the complete skill format. | |
| 145 | + | ||
| 146 | + | ## Manifest reference | |
| 147 | + | ||
| 148 | + | The current manifest shape is: | |
| 149 | + | ||
| 150 | + | ```json | |
| 151 | + | { | |
| 152 | + | "zotfile": 1, | |
| 153 | + | "name": "code-reviewer", | |
| 154 | + | "version": "0.1.0", | |
| 155 | + | "description": "Reviews a repository and reports actionable findings.", | |
| 156 | + | "license": "MIT", | |
| 157 | + | "runtime": { | |
| 158 | + | "min_zot": "0.2.76" | |
| 159 | + | }, | |
| 160 | + | "model": { | |
| 161 | + | "requires": ["tools", "reasoning"], | |
| 162 | + | "min_context": 64000, | |
| 163 | + | "preferred": ["claude-opus-4-5", "gpt-5.5"], | |
| 164 | + | "min_tier": "" | |
| 165 | + | }, | |
| 166 | + | "permissions": { | |
| 167 | + | "fs": { | |
| 168 | + | "read": ["${workspace}", "${agent_data}"], | |
| 169 | + | "write": ["${agent_data}"] | |
| 170 | + | }, | |
| 171 | + | "bash": { | |
| 172 | + | "mode": "allowlist", | |
| 173 | + | "allow": ["git", "go"] | |
| 174 | + | }, | |
| 175 | + | "net": { | |
| 176 | + | "allow": [] | |
| 177 | + | }, | |
| 178 | + | "env": { | |
| 179 | + | "read": [] | |
| 180 | + | } | |
| 181 | + | }, | |
| 182 | + | "requirements": { | |
| 183 | + | "bin": ["git", "go"], | |
| 184 | + | "os": ["darwin", "linux"] | |
| 185 | + | }, | |
| 186 | + | "entry": { | |
| 187 | + | "greeting": "What should I review?", | |
| 188 | + | "default_prompt": null | |
| 189 | + | }, | |
| 190 | + | "replace_system_prompt": false | |
| 191 | + | } | |
| 192 | + | ``` | |
| 193 | + | ||
| 194 | + | ### Top-level fields | |
| 195 | + | ||
| 196 | + | | Field | Required | Current behavior | | |
| 197 | + | |---|---:|---| | |
| 198 | + | | `zotfile` | yes | Format version. Must be `1`. | | |
| 199 | + | | `name` | yes | Local agent identity and session/data namespace. See naming rules below. | | |
| 200 | + | | `version` | no | Displayed in consent and inspection output. Semver is recommended but not currently validated. | | |
| 201 | + | | `description` | no | Displayed by `zot inspect`. | | |
| 202 | + | | `license` | no | Package metadata. Not otherwise interpreted by the local runtime. | | |
| 203 | + | | `runtime.min_zot` | no | Minimum zot version. Older binaries refuse to run the agent. Unversioned development builds cannot satisfy a non-empty minimum. | | |
| 204 | + | | `model` | no | Model capabilities, minimum context, and preferences. | | |
| 205 | + | | `permissions` | no | Filesystem and bash permission ceiling. Omitted scopes deny access. | | |
| 206 | + | | `requirements` | no | Required operating systems and executables. | | |
| 207 | + | | `entry` | no | Initial presentation and prompt metadata. | | |
| 208 | + | | `replace_system_prompt` | no | Replaces rather than appends to the normal system prompt when `true`. Defaults to `false`. | | |
| 209 | + | ||
| 210 | + | ### Name rules | |
| 211 | + | ||
| 212 | + | The local runtime currently accepts a flat lowercase name containing only: | |
| 213 | + | ||
| 214 | + | - `a` to `z` | |
| 215 | + | - `0` to `9` | |
| 216 | + | - `.`, `-`, and `_` | |
| 217 | + | ||
| 218 | + | Examples: | |
| 219 | + | ||
| 220 | + | ```text | |
| 221 | + | code-reviewer | |
| 222 | + | repo_onboarder | |
| 223 | + | acme.audit | |
| 224 | + | ``` | |
| 225 | + | ||
| 226 | + | Names containing `/`, uppercase letters, spaces, or path-like values such as `..` are rejected. Registry-style `namespace/agent` names are reserved for the future distribution runtime and are not accepted locally yet. | |
| 227 | + | ||
| 228 | + | ### Model requirements | |
| 229 | + | ||
| 230 | + | `model.requires` currently recognizes: | |
| 231 | + | ||
| 232 | + | | Capability | Behavior | | |
| 233 | + | |---|---| | |
| 234 | + | | `tools` | Accepted. Every model in the current catalog is treated as tool-capable. | | |
| 235 | + | | `reasoning` | Requires a catalog model marked as supporting reasoning. | | |
| 236 | + | | `vision` | Recognized, but currently fails closed because vision support is not represented in the model catalog. No model qualifies. | | |
| 237 | + | ||
| 238 | + | Unknown capabilities are rejected. Use `tools`, not the draft spelling `tool_calling`. | |
| 239 | + | ||
| 240 | + | `model.min_context` requires a catalog model with at least that many context tokens. | |
| 241 | + | ||
| 242 | + | `model.preferred` is an ordered list of model IDs. It is a preference, not a provider requirement. Selection proceeds as follows: | |
| 243 | + | ||
| 244 | + | 1. An explicitly selected model, if present, must satisfy the requirements. | |
| 245 | + | 2. The user's configured default is retained if it satisfies them. | |
| 246 | + | 3. The first compatible model in `preferred` is selected. | |
| 247 | + | 4. Otherwise, zot chooses a compatible active catalog model. | |
| 248 | + | 5. If none qualifies, execution stops with an error. | |
| 249 | + | ||
| 250 | + | `model.min_tier` is present in the format shape but is not supported by the local runtime. Leave it empty or omit it. A non-empty value is rejected. | |
| 251 | + | ||
| 252 | + | ### Runtime and host requirements | |
| 253 | + | ||
| 254 | + | Use `requirements.os` to restrict the agent to Go operating-system names: | |
| 255 | + | ||
| 256 | + | ```json | |
| 257 | + | { | |
| 258 | + | "requirements": { | |
| 259 | + | "os": ["darwin", "linux", "windows"] | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ``` | |
| 263 | + | ||
| 264 | + | Use `requirements.bin` for commands that must already be on `PATH`: | |
| 265 | + | ||
| 266 | + | ```json | |
| 267 | + | { | |
| 268 | + | "requirements": { | |
| 269 | + | "bin": ["git", "python3"] | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ``` | |
| 273 | + | ||
| 274 | + | Zot checks these requirements before requesting consent. Zotfiles do not have install or postinstall hooks, so authors must document how users can obtain missing programs. | |
| 275 | + | ||
| 276 | + | ### Entry fields | |
| 277 | + | ||
| 278 | + | `entry.default_prompt` is used when the user runs the agent without a prompt. It may be a string or `null`: | |
| 279 | + | ||
| 280 | + | ```json | |
| 281 | + | { | |
| 282 | + | "entry": { | |
| 283 | + | "default_prompt": "Review the current repository" | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ``` | |
| 287 | + | ||
| 288 | + | A prompt supplied on the command line takes precedence. | |
| 289 | + | ||
| 290 | + | `entry.greeting` is part of the manifest, but the current local runtime does not render it yet. | |
| 291 | + | ||
| 292 | + | ## Filesystem permissions | |
| 293 | + | ||
| 294 | + | Filesystem permissions are enforced by the built-in `read`, `write`, and `edit` tools. Empty or omitted scopes deny the operation. | |
| 295 | + | ||
| 296 | + | Two variables are available in filesystem scopes: | |
| 297 | + | ||
| 298 | + | | Variable | Resolves to | | |
| 299 | + | |---|---| | |
| 300 | + | | `${workspace}` | The current working directory used for the run. | | |
| 301 | + | | `${agent_data}` | The agent's persistent private data directory under `$ZOT_HOME/agents/<name>/data/`. | | |
| 302 | + | ||
| 303 | + | Relative paths are resolved beneath `${workspace}`. For example: | |
| 304 | + | ||
| 305 | + | ```json | |
| 306 | + | { | |
| 307 | + | "permissions": { | |
| 308 | + | "fs": { | |
| 309 | + | "read": ["src", "go.mod"], | |
| 310 | + | "write": ["${agent_data}"] | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ``` | |
| 315 | + | ||
| 316 | + | This allows reads under `<workspace>/src` and at `<workspace>/go.mod`, plus writes under the private agent data directory. It does not grant general workspace writes. | |
| 317 | + | ||
| 318 | + | Common permission profiles follow. | |
| 319 | + | ||
| 320 | + | Read-only repository agent: | |
| 321 | + | ||
| 322 | + | ```json | |
| 323 | + | { | |
| 324 | + | "permissions": { | |
| 325 | + | "fs": { | |
| 326 | + | "read": ["${workspace}"], | |
| 327 | + | "write": [] | |
| 328 | + | }, | |
| 329 | + | "bash": { "mode": "none" } | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ``` | |
| 333 | + | ||
| 334 | + | Repository editor with private persistent state: | |
| 335 | + | ||
| 336 | + | ```json | |
| 337 | + | { | |
| 338 | + | "permissions": { | |
| 339 | + | "fs": { | |
| 340 | + | "read": ["${workspace}", "${agent_data}"], | |
| 341 | + | "write": ["${workspace}", "${agent_data}"] | |
| 342 | + | }, | |
| 343 | + | "bash": { "mode": "none" } | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ``` | |
| 347 | + | ||
| 348 | + | Data-only agent: | |
| 349 | + | ||
| 350 | + | ```json | |
| 351 | + | { | |
| 352 | + | "permissions": { | |
| 353 | + | "fs": { | |
| 354 | + | "read": ["${agent_data}"], | |
| 355 | + | "write": ["${agent_data}"] | |
| 356 | + | }, | |
| 357 | + | "bash": { "mode": "none" } | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ``` | |
| 361 | + | ||
| 362 | + | Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope. | |
| 363 | + | ||
| 364 | + | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | |
| 365 | + | ||
| 366 | + | ## Bash permissions | |
| 367 | + | ||
| 368 | + | `permissions.bash.mode` supports three values. | |
| 369 | + | ||
| 370 | + | ### `none` | |
| 371 | + | ||
| 372 | + | ```json | |
| 373 | + | { | |
| 374 | + | "bash": { "mode": "none" } | |
| 375 | + | } | |
| 376 | + | ``` | |
| 377 | + | ||
| 378 | + | All bash tool calls are denied. This is also the default when `mode` is omitted. | |
| 379 | + | ||
| 380 | + | ### `ask` | |
| 381 | + | ||
| 382 | + | ```json | |
| 383 | + | { | |
| 384 | + | "bash": { "mode": "ask" } | |
| 385 | + | } | |
| 386 | + | ``` | |
| 387 | + | ||
| 388 | + | Bash is available after the user approves the agent at launch. Consent for an agent using `ask` is requested on every launch and is not cached. | |
| 389 | + | ||
| 390 | + | In the current local runtime, `ask` is launch-time capability consent. It does not open a separate confirmation dialog for every individual bash invocation. | |
| 391 | + | ||
| 392 | + | ### `allowlist` | |
| 393 | + | ||
| 394 | + | ```json | |
| 395 | + | { | |
| 396 | + | "bash": { | |
| 397 | + | "mode": "allowlist", | |
| 398 | + | "allow": ["git", "go", "grep"] | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ``` | |
| 402 | + | ||
| 403 | + | Every command in a shell expression must be listed. Zot checks commands separated by `;`, `&&`, `||`, and pipes. Paths are reduced to their base command name, so `/usr/bin/git` is checked as `git`. A leading environment assignment is skipped when identifying the command. | |
| 404 | + | ||
| 405 | + | The allowlist rejects shell substitution, newlines, redirection, backticks, `$()`, `<`, and `>`. This intentionally supports simple command pipelines rather than arbitrary shell programs. | |
| 406 | + | ||
| 407 | + | An `allowlist` mode with an empty `allow` array is invalid. | |
| 408 | + | ||
| 409 | + | Only declare commands the agent genuinely needs. `requirements.bin` checks that a command exists, while `permissions.bash.allow` controls whether the agent may invoke it. Agents that execute a required command normally need it in both places. | |
| 410 | + | ||
| 411 | + | ## Network and environment permissions | |
| 412 | + | ||
| 413 | + | The manifest shape includes `permissions.net.allow` and `permissions.env.read`, but the local runtime does not enforce them yet. To avoid presenting unenforced declarations as security controls, it rejects manifests containing any network host or environment variable. | |
| 414 | + | ||
| 415 | + | Use empty arrays or omit these sections: | |
| 416 | + | ||
| 417 | + | ```json | |
| 418 | + | { | |
| 419 | + | "permissions": { | |
| 420 | + | "net": { "allow": [] }, | |
| 421 | + | "env": { "read": [] } | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | ``` | |
| 425 | + | ||
| 426 | + | Bundled executable extensions are rejected for the same reason. Network filtering, scrubbed extension environments, and extension confinement belong to a later runtime milestone. | |
| 427 | + | ||
| 428 | + | ## Consent and persistent data | |
| 429 | + | ||
| 430 | + | Before the first run, zot prints the agent identity and expanded filesystem and bash permissions, then asks: | |
| 431 | + | ||
| 432 | + | ```text | |
| 433 | + | Agent code-reviewer@0.1.0 wants to run. | |
| 434 | + | ||
| 435 | + | fs read: /path/to/workspace | |
| 436 | + | fs write: none | |
| 437 | + | bash: none | |
| 438 | + | ||
| 439 | + | Allow? [y/N] | |
| 440 | + | ``` | |
| 441 | + | ||
| 442 | + | For modes other than `bash: ask`, approval is cached for the exact artifact digest under: | |
| 443 | + | ||
| 444 | + | ```text | |
| 445 | + | $ZOT_HOME/agents/<name>/consents/<digest>.json | |
| 446 | + | ``` | |
| 447 | + | ||
| 448 | + | Any change to the packaged artifact produces a different digest and requires consent again. `bash: ask` always requires fresh launch consent. | |
| 449 | + | ||
| 450 | + | Non-interactive runs refuse to bypass consent by default. For controlled automation, set: | |
| 451 | + | ||
| 452 | + | ```bash | |
| 453 | + | ZOT_AGENT_CONSENT=1 zot run ./code-reviewer.zot --print "Review this repository" | |
| 454 | + | ``` | |
| 455 | + | ||
| 456 | + | This environment variable skips the consent prompt. Only use it after independently inspecting and trusting the exact artifact being run. | |
| 457 | + | ||
| 458 | + | Persistent agent data lives under: | |
| 459 | + | ||
| 460 | + | ```text | |
| 461 | + | $ZOT_HOME/agents/<name>/data/ | |
| 462 | + | ``` | |
| 463 | + | ||
| 464 | + | The directory is created for every run, but the agent can access it only when `${agent_data}` appears in the relevant filesystem permission scope. | |
| 465 | + | ||
| 466 | + | ## Agent-scoped sessions | |
| 467 | + | ||
| 468 | + | Zotfile sessions are isolated from ordinary zot sessions and from other agents: | |
| 469 | + | ||
| 470 | + | ```text | |
| 471 | + | $ZOT_HOME/sessions/agents/<name>/ | |
| 472 | + | ``` | |
| 473 | + | ||
| 474 | + | Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. | |
| 475 | + | ||
| 476 | + | ## Commands | |
| 477 | + | ||
| 478 | + | ### `zot run` | |
| 479 | + | ||
| 480 | + | Run an unpackaged directory during development: | |
| 481 | + | ||
| 482 | + | ```bash | |
| 483 | + | zot run ./my-agent | |
| 484 | + | zot run ./my-agent "Do the task" | |
| 485 | + | ``` | |
| 486 | + | ||
| 487 | + | Run a packed artifact: | |
| 488 | + | ||
| 489 | + | ```bash | |
| 490 | + | zot run ./my-agent.zot | |
| 491 | + | zot run ./my-agent.zot --print "Do the task" | |
| 492 | + | zot run ./my-agent.zot --json "Do the task" | |
| 493 | + | ``` | |
| 494 | + | ||
| 495 | + | Arguments after the reference use zot's normal CLI parser, so model, provider, reasoning, cwd, session, tool, and output-mode flags remain available. The manifest still imposes its permission and compatibility ceiling. | |
| 496 | + | ||
| 497 | + | Local filesystem directories, local archive paths, and public GitHub agent-directory URLs are accepted. For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits: | |
| 498 | + | ||
| 499 | + | ```bash | |
| 500 | + | zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot | |
| 501 | + | ``` | |
| 502 | + | ||
| 503 | + | Both the short subdirectory form above and a standard GitHub tree URL are supported: | |
| 504 | + | ||
| 505 | + | ```bash | |
| 506 | + | zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance | |
| 507 | + | ``` | |
| 508 | + | ||
| 509 | + | The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. | |
| 510 | + | ||
| 511 | + | Installed names, arbitrary URLs, registry references, and OCI references are not resolved yet. | |
| 512 | + | ||
| 513 | + | ### `zot pack` | |
| 514 | + | ||
| 515 | + | ```bash | |
| 516 | + | zot pack [directory] [output] | |
| 517 | + | ``` | |
| 518 | + | ||
| 519 | + | Examples: | |
| 520 | + | ||
| 521 | + | ```bash | |
| 522 | + | zot pack | |
| 523 | + | zot pack ./my-agent | |
| 524 | + | zot pack ./my-agent ./dist/reviewer.zot | |
| 525 | + | ``` | |
| 526 | + | ||
| 527 | + | The default directory is the current directory. When output is omitted, zot uses `<manifest-name>.zot`. If the output has no `.zot` suffix, zot adds one. | |
| 528 | + | ||
| 529 | + | Packing validates the manifest and directory, then creates a zstd-compressed tar archive. Entries are sorted and tar metadata is normalized with fixed timestamps and numeric ownership. The output archive itself is excluded when it is located inside the source directory. Zot prints the SHA-256 digest of the resulting compressed artifact. | |
| 530 | + | ||
| 531 | + | ### `zot inspect` | |
| 532 | + | ||
| 533 | + | ```bash | |
| 534 | + | zot inspect ./my-agent | |
| 535 | + | zot inspect ./my-agent.zot | |
| 536 | + | zot inspect https://github.com/patriceckhart/agents/zot-maintenance | |
| 537 | + | ``` | |
| 538 | + | ||
| 539 | + | Inspection validates and prints the agent's name, version, description, digest, declared permissions, and complete file list. It does not execute the agent. | |
| 540 | + | ||
| 541 | + | For a directory, the digest is computed from its canonical uncompressed tar representation. For a `.zot` archive, the digest is computed from the archive bytes. Therefore a source-directory digest and its packed-archive digest are not expected to match. | |
| 542 | + | ||
| 543 | + | ### `zot verify` | |
| 544 | + | ||
| 545 | + | ```bash | |
| 546 | + | zot verify ./my-agent.zot | |
| 547 | + | ``` | |
| 548 | + | ||
| 549 | + | The local `verify` command validates that the archive can be safely loaded, validates the manifest and required layout, and prints its SHA-256 digest. | |
| 550 | + | ||
| 551 | + | Despite the command name, it does **not** verify a publisher signature in the current runtime. Signature and namespace verification are planned for registry distribution. | |
| 552 | + | ||
| 553 | + | ## Packaging and extraction limits | |
| 554 | + | ||
| 555 | + | The archive loader applies these limits: | |
| 556 | + | ||
| 557 | + | | Limit | Value | | |
| 558 | + | |---|---:| | |
| 559 | + | | Compressed `.zot` file | 100 MiB | | |
| 560 | + | | One extracted entry | 64 MiB | | |
| 561 | + | | Total extracted content | 256 MiB | | |
| 562 | + | ||
| 563 | + | Archive extraction rejects absolute paths and parent traversal. Unsupported tar entry types are ignored. New packages use zstd; gzip-compressed tar archives are accepted only as a compatibility fallback for earlier experiments. | |
| 564 | + | ||
| 565 | + | ## Authoring guidance | |
| 566 | + | ||
| 567 | + | 1. Start with the narrowest permissions possible. A reviewer usually needs repository reads, not writes or bash. | |
| 568 | + | 2. Keep `AGENT.md` focused on stable behavior. Put task-specific procedures in skills so the model loads them only when relevant. | |
| 569 | + | 3. Declare every external command in both `requirements.bin` and the bash allowlist when the agent must execute it. | |
| 570 | + | 4. Test from a directory first with `zot inspect` and `zot run`. | |
| 571 | + | 5. Test denied behavior as well as allowed behavior. Ask the agent to attempt an out-of-scope read, write, and command. | |
| 572 | + | 6. Pack the same source twice and compare behavior and output digests before distributing it. | |
| 573 | + | 7. Inspect and verify the final archive, not only its source directory. | |
| 574 | + | 8. Do not claim that network, environment, signatures, extensions, installation, or registry delivery are secured or supported by the local runtime. | |
| 575 | + | ||
| 576 | + | ## Current scope | |
| 577 | + | ||
| 578 | + | Implemented now: | |
| 579 | + | ||
| 580 | + | - local directories and `.zot` archives | |
| 581 | + | - temporary execution of agent directories from public GitHub repositories | |
| 582 | + | - `zot pack`, `zot inspect`, `zot verify`, and `zot run` | |
| 583 | + | - canonical tar creation with zstd compression | |
| 584 | + | - archive digest reporting and safe extraction limits | |
| 585 | + | - `AGENT.md` append or replacement behavior | |
| 586 | + | - bundled skills | |
| 587 | + | - runtime, OS, and binary requirements | |
| 588 | + | - model context and reasoning requirements | |
| 589 | + | - filesystem scope enforcement | |
| 590 | + | - bash `none`, `ask`, and `allowlist` modes | |
| 591 | + | - digest-scoped consent receipts | |
| 592 | + | - private agent data directories | |
| 593 | + | - agent-scoped sessions | |
| 594 | + | ||
| 595 | + | Not implemented yet: | |
| 596 | + | ||
| 597 | + | - `zot install`, `zot agents`, `zot use`, `zot update`, or `zot publish` | |
| 598 | + | - installed-name, arbitrary URL, OCI, or zot.sh index resolution | |
| 599 | + | - network allowlist enforcement | |
| 600 | + | - environment-variable filtering | |
| 601 | + | - safely confined bundled executable extensions | |
| 602 | + | - automatic artifact theme loading | |
| 603 | + | - agent dependency composition | |
| 604 | + | - per-agent Telegram, bot, or RPC selection | |
| 605 | + | - `--no-sandbox`, `--trust-bash`, or registry trust override flags | |
| 606 | + | ||
| 607 | + | Treat the unsupported fields and commands in the broader Zotfile proposal as forward-looking design, not as current runtime behavior. | |
| @@ -0,0 +1,607 @@ | |||||
| 1 | + | # Zotfile agents | |||
| 2 | + | ||||
| 3 | + | A Zotfile packages an agent's behavior into one portable `.zot` file. It can contain the agent's instructions, reusable skills, static assets, and metadata describing the runtime, model, operating-system, binary, and tool permissions it needs. | |||
| 4 | + | ||||
| 5 | + | The current implementation supports creating, inspecting, verifying, and running local directories and `.zot` archives. It can also run an agent directory directly from a public GitHub repository without keeping a clone. Registry distribution, installation, signatures, bundled executable extensions, network permissions, and environment permissions are not implemented yet. | |||
| 6 | + | ||||
| 7 | + | ## Quick start | |||
| 8 | + | ||||
| 9 | + | Create a directory for the agent: | |||
| 10 | + | ||||
| 11 | + | ```text | |||
| 12 | + | reviewer/ | |||
| 13 | + | ├── manifest.json | |||
| 14 | + | ├── AGENT.md | |||
| 15 | + | └── skills/ | |||
| 16 | + | └── code-review/SKILL.md | |||
| 17 | + | ``` | |||
| 18 | + | ||||
| 19 | + | Add a minimal `manifest.json`: | |||
| 20 | + | ||||
| 21 | + | ```json | |||
| 22 | + | { | |||
| 23 | + | "zotfile": 1, | |||
| 24 | + | "name": "code-reviewer", | |||
| 25 | + | "version": "0.1.0", | |||
| 26 | + | "description": "Reviews a repository and reports actionable findings.", | |||
| 27 | + | "runtime": { | |||
| 28 | + | "min_zot": "0.2.76" | |||
| 29 | + | }, | |||
| 30 | + | "model": { | |||
| 31 | + | "requires": ["tools"], | |||
| 32 | + | "min_context": 64000 | |||
| 33 | + | }, | |||
| 34 | + | "permissions": { | |||
| 35 | + | "fs": { | |||
| 36 | + | "read": ["${workspace}"], | |||
| 37 | + | "write": [] | |||
| 38 | + | }, | |||
| 39 | + | "bash": { | |||
| 40 | + | "mode": "none" | |||
| 41 | + | } | |||
| 42 | + | }, | |||
| 43 | + | "requirements": { | |||
| 44 | + | "os": ["darwin", "linux", "windows"] | |||
| 45 | + | }, | |||
| 46 | + | "entry": { | |||
| 47 | + | "greeting": "What should I review?", | |||
| 48 | + | "default_prompt": null | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ``` | |||
| 52 | + | ||||
| 53 | + | Add the agent's standing instructions to `AGENT.md`: | |||
| 54 | + | ||||
| 55 | + | ```markdown | |||
| 56 | + | # Code reviewer | |||
| 57 | + | ||||
| 58 | + | Review the current repository without modifying it. | |||
| 59 | + | ||||
| 60 | + | Prioritize correctness, security, regressions, and missing tests. Report findings | |||
| 61 | + | in severity order with file and line references. Do not praise the code or | |||
| 62 | + | summarize files that have no findings. | |||
| 63 | + | ``` | |||
| 64 | + | ||||
| 65 | + | Test the directory directly during development: | |||
| 66 | + | ||||
| 67 | + | ```bash | |||
| 68 | + | zot inspect ./reviewer | |||
| 69 | + | zot run ./reviewer | |||
| 70 | + | zot run ./reviewer "Review the authentication package" | |||
| 71 | + | ``` | |||
| 72 | + | ||||
| 73 | + | Package it when ready: | |||
| 74 | + | ||||
| 75 | + | ```bash | |||
| 76 | + | zot pack ./reviewer | |||
| 77 | + | # wrote code-reviewer.zot | |||
| 78 | + | # digest sha256:... | |||
| 79 | + | ||||
| 80 | + | zot verify ./code-reviewer.zot | |||
| 81 | + | zot run ./code-reviewer.zot | |||
| 82 | + | ``` | |||
| 83 | + | ||||
| 84 | + | The first run displays the declared permissions and asks for consent before the agent starts. | |||
| 85 | + | ||||
| 86 | + | ## Directory layout | |||
| 87 | + | ||||
| 88 | + | ```text | |||
| 89 | + | my-agent/ | |||
| 90 | + | ├── manifest.json # identity, requirements, and permissions, required | |||
| 91 | + | ├── AGENT.md # persona and standing instructions, required | |||
| 92 | + | ├── skills/ # SKILL.md directories, optional | |||
| 93 | + | │ └── my-skill/SKILL.md | |||
| 94 | + | ├── assets/ # static files, optional | |||
| 95 | + | └── README.md # human-facing documentation, optional | |||
| 96 | + | ``` | |||
| 97 | + | ||||
| 98 | + | Only `manifest.json` and a regular `AGENT.md` file are required. Other ordinary files and directories are included in the archive, but the runtime gives no special behavior to `assets/` or `README.md` yet. Files in `assets/` are not automatically added to the model context or granted filesystem access. | |||
| 99 | + | ||||
| 100 | + | Do not include executable extensions in `extensions/`. The local runtime rejects any bundled extension directory containing `extension.json` because extension subprocesses cannot yet be confined to the manifest permissions. This is a deliberate fail-closed restriction. | |||
| 101 | + | ||||
| 102 | + | Symlinks are not supported and cause `zot pack` to fail. | |||
| 103 | + | ||||
| 104 | + | ## `AGENT.md` | |||
| 105 | + | ||||
| 106 | + | By default, `AGENT.md` is appended to zot's system prompt. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. | |||
| 107 | + | ||||
| 108 | + | Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose. | |||
| 109 | + | ||||
| 110 | + | Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement system prompt: | |||
| 111 | + | ||||
| 112 | + | ```json | |||
| 113 | + | { | |||
| 114 | + | "replace_system_prompt": true | |||
| 115 | + | } | |||
| 116 | + | ``` | |||
| 117 | + | ||||
| 118 | + | Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. | |||
| 119 | + | ||||
| 120 | + | ## Bundled skills | |||
| 121 | + | ||||
| 122 | + | Place skills under `skills/<name>/SKILL.md` using the normal zot skill format: | |||
| 123 | + | ||||
| 124 | + | ```text | |||
| 125 | + | skills/ | |||
| 126 | + | └── investigate-failure/ | |||
| 127 | + | └── SKILL.md | |||
| 128 | + | ``` | |||
| 129 | + | ||||
| 130 | + | ```markdown | |||
| 131 | + | --- | |||
| 132 | + | name: investigate-failure | |||
| 133 | + | description: Diagnose a failing command from its output and relevant source files. | |||
| 134 | + | --- | |||
| 135 | + | ||||
| 136 | + | # Investigate failure | |||
| 137 | + | ||||
| 138 | + | 1. Reproduce the failure if permissions allow it. | |||
| 139 | + | 2. Identify the first meaningful error rather than downstream noise. | |||
| 140 | + | 3. Read the implementation and nearest tests. | |||
| 141 | + | 4. Propose the smallest correction and validation plan. | |||
| 142 | + | ``` | |||
| 143 | + | ||||
| 144 | + | Bundled skills are added to normal skill discovery while the Zotfile is running. The model sees their name and description in the skill manifest and can load the full body through the `skill` tool. See [skills.md](skills.md) for the complete skill format. | |||
| 145 | + | ||||
| 146 | + | ## Manifest reference | |||
| 147 | + | ||||
| 148 | + | The current manifest shape is: | |||
| 149 | + | ||||
| 150 | + | ```json | |||
| 151 | + | { | |||
| 152 | + | "zotfile": 1, | |||
| 153 | + | "name": "code-reviewer", | |||
| 154 | + | "version": "0.1.0", | |||
| 155 | + | "description": "Reviews a repository and reports actionable findings.", | |||
| 156 | + | "license": "MIT", | |||
| 157 | + | "runtime": { | |||
| 158 | + | "min_zot": "0.2.76" | |||
| 159 | + | }, | |||
| 160 | + | "model": { | |||
| 161 | + | "requires": ["tools", "reasoning"], | |||
| 162 | + | "min_context": 64000, | |||
| 163 | + | "preferred": ["claude-opus-4-5", "gpt-5.5"], | |||
| 164 | + | "min_tier": "" | |||
| 165 | + | }, | |||
| 166 | + | "permissions": { | |||
| 167 | + | "fs": { | |||
| 168 | + | "read": ["${workspace}", "${agent_data}"], | |||
| 169 | + | "write": ["${agent_data}"] | |||
| 170 | + | }, | |||
| 171 | + | "bash": { | |||
| 172 | + | "mode": "allowlist", | |||
| 173 | + | "allow": ["git", "go"] | |||
| 174 | + | }, | |||
| 175 | + | "net": { | |||
| 176 | + | "allow": [] | |||
| 177 | + | }, | |||
| 178 | + | "env": { | |||
| 179 | + | "read": [] | |||
| 180 | + | } | |||
| 181 | + | }, | |||
| 182 | + | "requirements": { | |||
| 183 | + | "bin": ["git", "go"], | |||
| 184 | + | "os": ["darwin", "linux"] | |||
| 185 | + | }, | |||
| 186 | + | "entry": { | |||
| 187 | + | "greeting": "What should I review?", | |||
| 188 | + | "default_prompt": null | |||
| 189 | + | }, | |||
| 190 | + | "replace_system_prompt": false | |||
| 191 | + | } | |||
| 192 | + | ``` | |||
| 193 | + | ||||
| 194 | + | ### Top-level fields | |||
| 195 | + | ||||
| 196 | + | | Field | Required | Current behavior | | |||
| 197 | + | |---|---:|---| | |||
| 198 | + | | `zotfile` | yes | Format version. Must be `1`. | | |||
| 199 | + | | `name` | yes | Local agent identity and session/data namespace. See naming rules below. | | |||
| 200 | + | | `version` | no | Displayed in consent and inspection output. Semver is recommended but not currently validated. | | |||
| 201 | + | | `description` | no | Displayed by `zot inspect`. | | |||
| 202 | + | | `license` | no | Package metadata. Not otherwise interpreted by the local runtime. | | |||
| 203 | + | | `runtime.min_zot` | no | Minimum zot version. Older binaries refuse to run the agent. Unversioned development builds cannot satisfy a non-empty minimum. | | |||
| 204 | + | | `model` | no | Model capabilities, minimum context, and preferences. | | |||
| 205 | + | | `permissions` | no | Filesystem and bash permission ceiling. Omitted scopes deny access. | | |||
| 206 | + | | `requirements` | no | Required operating systems and executables. | | |||
| 207 | + | | `entry` | no | Initial presentation and prompt metadata. | | |||
| 208 | + | | `replace_system_prompt` | no | Replaces rather than appends to the normal system prompt when `true`. Defaults to `false`. | | |||
| 209 | + | ||||
| 210 | + | ### Name rules | |||
| 211 | + | ||||
| 212 | + | The local runtime currently accepts a flat lowercase name containing only: | |||
| 213 | + | ||||
| 214 | + | - `a` to `z` | |||
| 215 | + | - `0` to `9` | |||
| 216 | + | - `.`, `-`, and `_` | |||
| 217 | + | ||||
| 218 | + | Examples: | |||
| 219 | + | ||||
| 220 | + | ```text | |||
| 221 | + | code-reviewer | |||
| 222 | + | repo_onboarder | |||
| 223 | + | acme.audit | |||
| 224 | + | ``` | |||
| 225 | + | ||||
| 226 | + | Names containing `/`, uppercase letters, spaces, or path-like values such as `..` are rejected. Registry-style `namespace/agent` names are reserved for the future distribution runtime and are not accepted locally yet. | |||
| 227 | + | ||||
| 228 | + | ### Model requirements | |||
| 229 | + | ||||
| 230 | + | `model.requires` currently recognizes: | |||
| 231 | + | ||||
| 232 | + | | Capability | Behavior | | |||
| 233 | + | |---|---| | |||
| 234 | + | | `tools` | Accepted. Every model in the current catalog is treated as tool-capable. | | |||
| 235 | + | | `reasoning` | Requires a catalog model marked as supporting reasoning. | | |||
| 236 | + | | `vision` | Recognized, but currently fails closed because vision support is not represented in the model catalog. No model qualifies. | | |||
| 237 | + | ||||
| 238 | + | Unknown capabilities are rejected. Use `tools`, not the draft spelling `tool_calling`. | |||
| 239 | + | ||||
| 240 | + | `model.min_context` requires a catalog model with at least that many context tokens. | |||
| 241 | + | ||||
| 242 | + | `model.preferred` is an ordered list of model IDs. It is a preference, not a provider requirement. Selection proceeds as follows: | |||
| 243 | + | ||||
| 244 | + | 1. An explicitly selected model, if present, must satisfy the requirements. | |||
| 245 | + | 2. The user's configured default is retained if it satisfies them. | |||
| 246 | + | 3. The first compatible model in `preferred` is selected. | |||
| 247 | + | 4. Otherwise, zot chooses a compatible active catalog model. | |||
| 248 | + | 5. If none qualifies, execution stops with an error. | |||
| 249 | + | ||||
| 250 | + | `model.min_tier` is present in the format shape but is not supported by the local runtime. Leave it empty or omit it. A non-empty value is rejected. | |||
| 251 | + | ||||
| 252 | + | ### Runtime and host requirements | |||
| 253 | + | ||||
| 254 | + | Use `requirements.os` to restrict the agent to Go operating-system names: | |||
| 255 | + | ||||
| 256 | + | ```json | |||
| 257 | + | { | |||
| 258 | + | "requirements": { | |||
| 259 | + | "os": ["darwin", "linux", "windows"] | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ``` | |||
| 263 | + | ||||
| 264 | + | Use `requirements.bin` for commands that must already be on `PATH`: | |||
| 265 | + | ||||
| 266 | + | ```json | |||
| 267 | + | { | |||
| 268 | + | "requirements": { | |||
| 269 | + | "bin": ["git", "python3"] | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | ``` | |||
| 273 | + | ||||
| 274 | + | Zot checks these requirements before requesting consent. Zotfiles do not have install or postinstall hooks, so authors must document how users can obtain missing programs. | |||
| 275 | + | ||||
| 276 | + | ### Entry fields | |||
| 277 | + | ||||
| 278 | + | `entry.default_prompt` is used when the user runs the agent without a prompt. It may be a string or `null`: | |||
| 279 | + | ||||
| 280 | + | ```json | |||
| 281 | + | { | |||
| 282 | + | "entry": { | |||
| 283 | + | "default_prompt": "Review the current repository" | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ``` | |||
| 287 | + | ||||
| 288 | + | A prompt supplied on the command line takes precedence. | |||
| 289 | + | ||||
| 290 | + | `entry.greeting` is part of the manifest, but the current local runtime does not render it yet. | |||
| 291 | + | ||||
| 292 | + | ## Filesystem permissions | |||
| 293 | + | ||||
| 294 | + | Filesystem permissions are enforced by the built-in `read`, `write`, and `edit` tools. Empty or omitted scopes deny the operation. | |||
| 295 | + | ||||
| 296 | + | Two variables are available in filesystem scopes: | |||
| 297 | + | ||||
| 298 | + | | Variable | Resolves to | | |||
| 299 | + | |---|---| | |||
| 300 | + | | `${workspace}` | The current working directory used for the run. | | |||
| 301 | + | | `${agent_data}` | The agent's persistent private data directory under `$ZOT_HOME/agents/<name>/data/`. | | |||
| 302 | + | ||||
| 303 | + | Relative paths are resolved beneath `${workspace}`. For example: | |||
| 304 | + | ||||
| 305 | + | ```json | |||
| 306 | + | { | |||
| 307 | + | "permissions": { | |||
| 308 | + | "fs": { | |||
| 309 | + | "read": ["src", "go.mod"], | |||
| 310 | + | "write": ["${agent_data}"] | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ``` | |||
| 315 | + | ||||
| 316 | + | This allows reads under `<workspace>/src` and at `<workspace>/go.mod`, plus writes under the private agent data directory. It does not grant general workspace writes. | |||
| 317 | + | ||||
| 318 | + | Common permission profiles follow. | |||
| 319 | + | ||||
| 320 | + | Read-only repository agent: | |||
| 321 | + | ||||
| 322 | + | ```json | |||
| 323 | + | { | |||
| 324 | + | "permissions": { | |||
| 325 | + | "fs": { | |||
| 326 | + | "read": ["${workspace}"], | |||
| 327 | + | "write": [] | |||
| 328 | + | }, | |||
| 329 | + | "bash": { "mode": "none" } | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ``` | |||
| 333 | + | ||||
| 334 | + | Repository editor with private persistent state: | |||
| 335 | + | ||||
| 336 | + | ```json | |||
| 337 | + | { | |||
| 338 | + | "permissions": { | |||
| 339 | + | "fs": { | |||
| 340 | + | "read": ["${workspace}", "${agent_data}"], | |||
| 341 | + | "write": ["${workspace}", "${agent_data}"] | |||
| 342 | + | }, | |||
| 343 | + | "bash": { "mode": "none" } | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | ``` | |||
| 347 | + | ||||
| 348 | + | Data-only agent: | |||
| 349 | + | ||||
| 350 | + | ```json | |||
| 351 | + | { | |||
| 352 | + | "permissions": { | |||
| 353 | + | "fs": { | |||
| 354 | + | "read": ["${agent_data}"], | |||
| 355 | + | "write": ["${agent_data}"] | |||
| 356 | + | }, | |||
| 357 | + | "bash": { "mode": "none" } | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ``` | |||
| 361 | + | ||||
| 362 | + | Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope. | |||
| 363 | + | ||||
| 364 | + | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | |||
| 365 | + | ||||
| 366 | + | ## Bash permissions | |||
| 367 | + | ||||
| 368 | + | `permissions.bash.mode` supports three values. | |||
| 369 | + | ||||
| 370 | + | ### `none` | |||
| 371 | + | ||||
| 372 | + | ```json | |||
| 373 | + | { | |||
| 374 | + | "bash": { "mode": "none" } | |||
| 375 | + | } | |||
| 376 | + | ``` | |||
| 377 | + | ||||
| 378 | + | All bash tool calls are denied. This is also the default when `mode` is omitted. | |||
| 379 | + | ||||
| 380 | + | ### `ask` | |||
| 381 | + | ||||
| 382 | + | ```json | |||
| 383 | + | { | |||
| 384 | + | "bash": { "mode": "ask" } | |||
| 385 | + | } | |||
| 386 | + | ``` | |||
| 387 | + | ||||
| 388 | + | Bash is available after the user approves the agent at launch. Consent for an agent using `ask` is requested on every launch and is not cached. | |||
| 389 | + | ||||
| 390 | + | In the current local runtime, `ask` is launch-time capability consent. It does not open a separate confirmation dialog for every individual bash invocation. | |||
| 391 | + | ||||
| 392 | + | ### `allowlist` | |||
| 393 | + | ||||
| 394 | + | ```json | |||
| 395 | + | { | |||
| 396 | + | "bash": { | |||
| 397 | + | "mode": "allowlist", | |||
| 398 | + | "allow": ["git", "go", "grep"] | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ``` | |||
| 402 | + | ||||
| 403 | + | Every command in a shell expression must be listed. Zot checks commands separated by `;`, `&&`, `||`, and pipes. Paths are reduced to their base command name, so `/usr/bin/git` is checked as `git`. A leading environment assignment is skipped when identifying the command. | |||
| 404 | + | ||||
| 405 | + | The allowlist rejects shell substitution, newlines, redirection, backticks, `$()`, `<`, and `>`. This intentionally supports simple command pipelines rather than arbitrary shell programs. | |||
| 406 | + | ||||
| 407 | + | An `allowlist` mode with an empty `allow` array is invalid. | |||
| 408 | + | ||||
| 409 | + | Only declare commands the agent genuinely needs. `requirements.bin` checks that a command exists, while `permissions.bash.allow` controls whether the agent may invoke it. Agents that execute a required command normally need it in both places. | |||
| 410 | + | ||||
| 411 | + | ## Network and environment permissions | |||
| 412 | + | ||||
| 413 | + | The manifest shape includes `permissions.net.allow` and `permissions.env.read`, but the local runtime does not enforce them yet. To avoid presenting unenforced declarations as security controls, it rejects manifests containing any network host or environment variable. | |||
| 414 | + | ||||
| 415 | + | Use empty arrays or omit these sections: | |||
| 416 | + | ||||
| 417 | + | ```json | |||
| 418 | + | { | |||
| 419 | + | "permissions": { | |||
| 420 | + | "net": { "allow": [] }, | |||
| 421 | + | "env": { "read": [] } | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | ``` | |||
| 425 | + | ||||
| 426 | + | Bundled executable extensions are rejected for the same reason. Network filtering, scrubbed extension environments, and extension confinement belong to a later runtime milestone. | |||
| 427 | + | ||||
| 428 | + | ## Consent and persistent data | |||
| 429 | + | ||||
| 430 | + | Before the first run, zot prints the agent identity and expanded filesystem and bash permissions, then asks: | |||
| 431 | + | ||||
| 432 | + | ```text | |||
| 433 | + | Agent code-reviewer@0.1.0 wants to run. | |||
| 434 | + | ||||
| 435 | + | fs read: /path/to/workspace | |||
| 436 | + | fs write: none | |||
| 437 | + | bash: none | |||
| 438 | + | ||||
| 439 | + | Allow? [y/N] | |||
| 440 | + | ``` | |||
| 441 | + | ||||
| 442 | + | For modes other than `bash: ask`, approval is cached for the exact artifact digest under: | |||
| 443 | + | ||||
| 444 | + | ```text | |||
| 445 | + | $ZOT_HOME/agents/<name>/consents/<digest>.json | |||
| 446 | + | ``` | |||
| 447 | + | ||||
| 448 | + | Any change to the packaged artifact produces a different digest and requires consent again. `bash: ask` always requires fresh launch consent. | |||
| 449 | + | ||||
| 450 | + | Non-interactive runs refuse to bypass consent by default. For controlled automation, set: | |||
| 451 | + | ||||
| 452 | + | ```bash | |||
| 453 | + | ZOT_AGENT_CONSENT=1 zot run ./code-reviewer.zot --print "Review this repository" | |||
| 454 | + | ``` | |||
| 455 | + | ||||
| 456 | + | This environment variable skips the consent prompt. Only use it after independently inspecting and trusting the exact artifact being run. | |||
| 457 | + | ||||
| 458 | + | Persistent agent data lives under: | |||
| 459 | + | ||||
| 460 | + | ```text | |||
| 461 | + | $ZOT_HOME/agents/<name>/data/ | |||
| 462 | + | ``` | |||
| 463 | + | ||||
| 464 | + | The directory is created for every run, but the agent can access it only when `${agent_data}` appears in the relevant filesystem permission scope. | |||
| 465 | + | ||||
| 466 | + | ## Agent-scoped sessions | |||
| 467 | + | ||||
| 468 | + | Zotfile sessions are isolated from ordinary zot sessions and from other agents: | |||
| 469 | + | ||||
| 470 | + | ```text | |||
| 471 | + | $ZOT_HOME/sessions/agents/<name>/ | |||
| 472 | + | ``` | |||
| 473 | + | ||||
| 474 | + | Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. | |||
| 475 | + | ||||
| 476 | + | ## Commands | |||
| 477 | + | ||||
| 478 | + | ### `zot run` | |||
| 479 | + | ||||
| 480 | + | Run an unpackaged directory during development: | |||
| 481 | + | ||||
| 482 | + | ```bash | |||
| 483 | + | zot run ./my-agent | |||
| 484 | + | zot run ./my-agent "Do the task" | |||
| 485 | + | ``` | |||
| 486 | + | ||||
| 487 | + | Run a packed artifact: | |||
| 488 | + | ||||
| 489 | + | ```bash | |||
| 490 | + | zot run ./my-agent.zot | |||
| 491 | + | zot run ./my-agent.zot --print "Do the task" | |||
| 492 | + | zot run ./my-agent.zot --json "Do the task" | |||
| 493 | + | ``` | |||
| 494 | + | ||||
| 495 | + | Arguments after the reference use zot's normal CLI parser, so model, provider, reasoning, cwd, session, tool, and output-mode flags remain available. The manifest still imposes its permission and compatibility ceiling. | |||
| 496 | + | ||||
| 497 | + | Local filesystem directories, local archive paths, and public GitHub agent-directory URLs are accepted. For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits: | |||
| 498 | + | ||||
| 499 | + | ```bash | |||
| 500 | + | zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot | |||
| 501 | + | ``` | |||
| 502 | + | ||||
| 503 | + | Both the short subdirectory form above and a standard GitHub tree URL are supported: | |||
| 504 | + | ||||
| 505 | + | ```bash | |||
| 506 | + | zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance | |||
| 507 | + | ``` | |||
| 508 | + | ||||
| 509 | + | The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. | |||
| 510 | + | ||||
| 511 | + | Installed names, arbitrary URLs, registry references, and OCI references are not resolved yet. | |||
| 512 | + | ||||
| 513 | + | ### `zot pack` | |||
| 514 | + | ||||
| 515 | + | ```bash | |||
| 516 | + | zot pack [directory] [output] | |||
| 517 | + | ``` | |||
| 518 | + | ||||
| 519 | + | Examples: | |||
| 520 | + | ||||
| 521 | + | ```bash | |||
| 522 | + | zot pack | |||
| 523 | + | zot pack ./my-agent | |||
| 524 | + | zot pack ./my-agent ./dist/reviewer.zot | |||
| 525 | + | ``` | |||
| 526 | + | ||||
| 527 | + | The default directory is the current directory. When output is omitted, zot uses `<manifest-name>.zot`. If the output has no `.zot` suffix, zot adds one. | |||
| 528 | + | ||||
| 529 | + | Packing validates the manifest and directory, then creates a zstd-compressed tar archive. Entries are sorted and tar metadata is normalized with fixed timestamps and numeric ownership. The output archive itself is excluded when it is located inside the source directory. Zot prints the SHA-256 digest of the resulting compressed artifact. | |||
| 530 | + | ||||
| 531 | + | ### `zot inspect` | |||
| 532 | + | ||||
| 533 | + | ```bash | |||
| 534 | + | zot inspect ./my-agent | |||
| 535 | + | zot inspect ./my-agent.zot | |||
| 536 | + | zot inspect https://github.com/patriceckhart/agents/zot-maintenance | |||
| 537 | + | ``` | |||
| 538 | + | ||||
| 539 | + | Inspection validates and prints the agent's name, version, description, digest, declared permissions, and complete file list. It does not execute the agent. | |||
| 540 | + | ||||
| 541 | + | For a directory, the digest is computed from its canonical uncompressed tar representation. For a `.zot` archive, the digest is computed from the archive bytes. Therefore a source-directory digest and its packed-archive digest are not expected to match. | |||
| 542 | + | ||||
| 543 | + | ### `zot verify` | |||
| 544 | + | ||||
| 545 | + | ```bash | |||
| 546 | + | zot verify ./my-agent.zot | |||
| 547 | + | ``` | |||
| 548 | + | ||||
| 549 | + | The local `verify` command validates that the archive can be safely loaded, validates the manifest and required layout, and prints its SHA-256 digest. | |||
| 550 | + | ||||
| 551 | + | Despite the command name, it does **not** verify a publisher signature in the current runtime. Signature and namespace verification are planned for registry distribution. | |||
| 552 | + | ||||
| 553 | + | ## Packaging and extraction limits | |||
| 554 | + | ||||
| 555 | + | The archive loader applies these limits: | |||
| 556 | + | ||||
| 557 | + | | Limit | Value | | |||
| 558 | + | |---|---:| | |||
| 559 | + | | Compressed `.zot` file | 100 MiB | | |||
| 560 | + | | One extracted entry | 64 MiB | | |||
| 561 | + | | Total extracted content | 256 MiB | | |||
| 562 | + | ||||
| 563 | + | Archive extraction rejects absolute paths and parent traversal. Unsupported tar entry types are ignored. New packages use zstd; gzip-compressed tar archives are accepted only as a compatibility fallback for earlier experiments. | |||
| 564 | + | ||||
| 565 | + | ## Authoring guidance | |||
| 566 | + | ||||
| 567 | + | 1. Start with the narrowest permissions possible. A reviewer usually needs repository reads, not writes or bash. | |||
| 568 | + | 2. Keep `AGENT.md` focused on stable behavior. Put task-specific procedures in skills so the model loads them only when relevant. | |||
| 569 | + | 3. Declare every external command in both `requirements.bin` and the bash allowlist when the agent must execute it. | |||
| 570 | + | 4. Test from a directory first with `zot inspect` and `zot run`. | |||
| 571 | + | 5. Test denied behavior as well as allowed behavior. Ask the agent to attempt an out-of-scope read, write, and command. | |||
| 572 | + | 6. Pack the same source twice and compare behavior and output digests before distributing it. | |||
| 573 | + | 7. Inspect and verify the final archive, not only its source directory. | |||
| 574 | + | 8. Do not claim that network, environment, signatures, extensions, installation, or registry delivery are secured or supported by the local runtime. | |||
| 575 | + | ||||
| 576 | + | ## Current scope | |||
| 577 | + | ||||
| 578 | + | Implemented now: | |||
| 579 | + | ||||
| 580 | + | - local directories and `.zot` archives | |||
| 581 | + | - temporary execution of agent directories from public GitHub repositories | |||
| 582 | + | - `zot pack`, `zot inspect`, `zot verify`, and `zot run` | |||
| 583 | + | - canonical tar creation with zstd compression | |||
| 584 | + | - archive digest reporting and safe extraction limits | |||
| 585 | + | - `AGENT.md` append or replacement behavior | |||
| 586 | + | - bundled skills | |||
| 587 | + | - runtime, OS, and binary requirements | |||
| 588 | + | - model context and reasoning requirements | |||
| 589 | + | - filesystem scope enforcement | |||
| 590 | + | - bash `none`, `ask`, and `allowlist` modes | |||
| 591 | + | - digest-scoped consent receipts | |||
| 592 | + | - private agent data directories | |||
| 593 | + | - agent-scoped sessions | |||
| 594 | + | ||||
| 595 | + | Not implemented yet: | |||
| 596 | + | ||||
| 597 | + | - `zot install`, `zot agents`, `zot use`, `zot update`, or `zot publish` | |||
| 598 | + | - installed-name, arbitrary URL, OCI, or zot.sh index resolution | |||
| 599 | + | - network allowlist enforcement | |||
| 600 | + | - environment-variable filtering | |||
| 601 | + | - safely confined bundled executable extensions | |||
| 602 | + | - automatic artifact theme loading | |||
| 603 | + | - agent dependency composition | |||
| 604 | + | - per-agent Telegram, bot, or RPC selection | |||
| 605 | + | - `--no-sandbox`, `--trust-bash`, or registry trust override flags | |||
| 606 | + | ||||
| 607 | + | Treat the unsupported fields and commands in the broader Zotfile proposal as forward-looking design, not as current runtime behavior. | |||
M go.mod +1 -0
| @@ -5,6 +5,7 @@ go 1.25.0 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/alecthomas/chroma/v2 v2.23.1 | |
| 7 | 7 | github.com/google/uuid v1.6.0 | |
| 8 | + | github.com/klauspost/compress v1.19.0 | |
| 8 | 9 | github.com/mattn/go-runewidth v0.0.16 | |
| 9 | 10 | github.com/sahilm/fuzzy v0.1.1 | |
| 10 | 11 | golang.org/x/image v0.43.0 | |
| @@ -5,6 +5,7 @@ go 1.25.0 | |||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/alecthomas/chroma/v2 v2.23.1 | 6 | github.com/alecthomas/chroma/v2 v2.23.1 | ||
| 7 | github.com/google/uuid v1.6.0 | 7 | github.com/google/uuid v1.6.0 | ||
| 8 | + | github.com/klauspost/compress v1.19.0 | |||
| 8 | github.com/mattn/go-runewidth v0.0.16 | 9 | github.com/mattn/go-runewidth v0.0.16 | ||
| 9 | github.com/sahilm/fuzzy v0.1.1 | 10 | github.com/sahilm/fuzzy v0.1.1 | ||
| 10 | golang.org/x/image v0.43.0 | 11 | golang.org/x/image v0.43.0 | ||
M go.sum +2 -0 generated file: collapsed
M packages/agent/args.go +8 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strconv" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 9 | 10 | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | 11 | "golang.org/x/term" | |
| 11 | 12 | ) | |
| @@ -100,6 +101,13 @@ type Args struct { | |||
| 100 | 101 | // swarm-spawned agent. Empty in every other mode. Set by | |
| 101 | 102 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | |
| 102 | 103 | SwarmAgent string | |
| 104 | + | ||
| 105 | + | // AgentName/AgentDataDir/PermissionSet are populated by `zot run` | |
| 106 | + | // for local Zotfile agents. They scope sessions and enforce the | |
| 107 | + | // manifest's declared file/bash permissions. | |
| 108 | + | AgentName string | |
| 109 | + | AgentDataDir string | |
| 110 | + | PermissionSet *tools.PermissionSet | |
| 103 | 111 | } | |
| 104 | 112 | ||
| 105 | 113 | // ParseArgs parses the process arguments (excluding argv[0]). | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 9 | "github.com/patriceckhart/zot/packages/tui" | 10 | "github.com/patriceckhart/zot/packages/tui" | ||
| 10 | "golang.org/x/term" | 11 | "golang.org/x/term" | ||
| 11 | ) | 12 | ) | ||
| @@ -100,6 +101,13 @@ type Args struct { | |||||
| 100 | // swarm-spawned agent. Empty in every other mode. Set by | 101 | // swarm-spawned agent. Empty in every other mode. Set by | ||
| 101 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | 102 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | ||
| 102 | SwarmAgent string | 103 | SwarmAgent string | ||
| 104 | + | ||||
| 105 | + | // AgentName/AgentDataDir/PermissionSet are populated by `zot run` | |||
| 106 | + | // for local Zotfile agents. They scope sessions and enforce the | |||
| 107 | + | // manifest's declared file/bash permissions. | |||
| 108 | + | AgentName string | |||
| 109 | + | AgentDataDir string | |||
| 110 | + | PermissionSet *tools.PermissionSet | |||
| 103 | } | 111 | } | ||
| 104 | 112 | ||||
| 105 | // ParseArgs parses the process arguments (excluding argv[0]). | 113 | // ParseArgs parses the process arguments (excluding argv[0]). | ||
M packages/agent/build.go +6 -0
| @@ -528,6 +528,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 528 | 528 | } | |
| 529 | 529 | ||
| 530 | 530 | sandbox := tools.NewSandbox(args.CWD) | |
| 531 | + | if args.PermissionSet != nil { | |
| 532 | + | sandbox.SetPermissions(args.PermissionSet) | |
| 533 | + | } | |
| 531 | 534 | reg := buildToolRegistry(args, args.CWD, sandbox) | |
| 532 | 535 | ||
| 533 | 536 | docsDir, _ := zotdocs.EnsureInstalled(ZotHome()) | |
| @@ -877,6 +880,9 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 877 | 880 | if s == nil || r == nil { | |
| 878 | 881 | return | |
| 879 | 882 | } | |
| 883 | + | if r.Sandbox != nil && r.Sandbox.Permissions != nil { | |
| 884 | + | s.Permissions = r.Sandbox.Permissions | |
| 885 | + | } | |
| 880 | 886 | r.Sandbox = s | |
| 881 | 887 | for name, t := range r.ToolRegistry { | |
| 882 | 888 | switch v := t.(type) { | |
| @@ -528,6 +528,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 528 | } | 528 | } | ||
| 529 | 529 | ||||
| 530 | sandbox := tools.NewSandbox(args.CWD) | 530 | sandbox := tools.NewSandbox(args.CWD) | ||
| 531 | + | if args.PermissionSet != nil { | |||
| 532 | + | sandbox.SetPermissions(args.PermissionSet) | |||
| 533 | + | } | |||
| 531 | reg := buildToolRegistry(args, args.CWD, sandbox) | 534 | reg := buildToolRegistry(args, args.CWD, sandbox) | ||
| 532 | 535 | ||||
| 533 | docsDir, _ := zotdocs.EnsureInstalled(ZotHome()) | 536 | docsDir, _ := zotdocs.EnsureInstalled(ZotHome()) | ||
| @@ -877,6 +880,9 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||||
| 877 | if s == nil || r == nil { | 880 | if s == nil || r == nil { | ||
| 878 | return | 881 | return | ||
| 879 | } | 882 | } | ||
| 883 | + | if r.Sandbox != nil && r.Sandbox.Permissions != nil { | |||
| 884 | + | s.Permissions = r.Sandbox.Permissions | |||
| 885 | + | } | |||
| 880 | r.Sandbox = s | 886 | r.Sandbox = s | ||
| 881 | for name, t := range r.ToolRegistry { | 887 | for name, t := range r.ToolRegistry { | ||
| 882 | switch v := t.(type) { | 888 | switch v := t.(type) { | ||
M packages/agent/cli.go +33 -34
| @@ -189,6 +189,13 @@ func Run(rawArgs []string, version string) error { | |||
| 189 | 189 | if handled, err := runUpdateCommand(rawArgs, version); handled { | |
| 190 | 190 | return err | |
| 191 | 191 | } | |
| 192 | + | if handled, err := runZotfileCommand(rawArgs, version); handled { | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | return runWithArgsRaw(rawArgs, version) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func runWithArgsRaw(rawArgs []string, version string) error { | |
| 192 | 199 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | |
| 193 | 200 | // spawn the binary with a clean argv. Strip the leading 'rpc' | |
| 194 | 201 | // token and let the rest flow through the normal arg parser. | |
| @@ -211,39 +218,18 @@ func Run(rawArgs []string, version string) error { | |||
| 211 | 218 | } | |
| 212 | 219 | // Model catalog: load any cached discovery data before we inspect | |
| 213 | 220 | // the model list (list-models, print/json, interactive). | |
| 214 | - | LoadCachedModels() | |
| 215 | - | LoadUserModels() | |
| 216 | - | ||
| 217 | - | // Register custom provider names with the auth package so they | |
| 218 | - | // participate in /login API-key flow and provider pickers. | |
| 219 | - | if cps := provider.CustomProviders(); len(cps) > 0 { | |
| 220 | - | names := make([]string, 0, len(cps)) | |
| 221 | - | for name := range cps { | |
| 222 | - | if !isBuiltinProvider(name) { | |
| 223 | - | names = append(names, name) | |
| 224 | - | } | |
| 225 | - | } | |
| 226 | - | auth.SetExtraAPIKeyProviders(names) | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | // Repair config.json so a stale (provider, model) pair from an | |
| 230 | - | // interrupted /model switch can't strand the user with an | |
| 231 | - | // "unknown model" error on the first turn. Runs before any UI | |
| 232 | - | // renders so the status bar shows the post-repair pair, not the | |
| 233 | - | // broken one. Silent on success. | |
| 234 | - | ValidateAndRepairConfig() | |
| 221 | + | prepareRuntimeCatalog() | |
| 235 | 222 | ||
| 236 | 223 | if args.ListModels { | |
| 237 | 224 | printModels() | |
| 238 | 225 | return nil | |
| 239 | 226 | } | |
| 240 | 227 | ||
| 241 | - | ctx := context.Background() | |
| 242 | - | ||
| 243 | - | // Kick an async refresh of the live model catalog. The first run of | |
| 244 | - | // zot hits the network; subsequent runs within CacheTTL do nothing. | |
| 245 | - | RefreshModelsAsync() | |
| 228 | + | return runWithArgs(args, version) | |
| 229 | + | } | |
| 246 | 230 | ||
| 231 | + | func runWithArgs(args Args, version string) error { | |
| 232 | + | ctx := context.Background() | |
| 247 | 233 | switch args.Mode { | |
| 248 | 234 | case ModePrint: | |
| 249 | 235 | return runPrintMode(ctx, args, version) | |
| @@ -835,6 +821,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 835 | 821 | wasJailed := sharedSandbox != nil && sharedSandbox.Locked() | |
| 836 | 822 | args.CWD = absPath | |
| 837 | 823 | r.CWD = absPath | |
| 824 | + | if args.PermissionSet != nil { | |
| 825 | + | expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir) | |
| 826 | + | args.PermissionSet = &expanded | |
| 827 | + | } | |
| 838 | 828 | if sharedSandbox != nil { | |
| 839 | 829 | sharedSandbox.Root = absPath | |
| 840 | 830 | if wasJailed { | |
| @@ -857,8 +847,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 857 | 847 | // because /cd's semantics are "start fresh here", matching | |
| 858 | 848 | // what relaunching `zot --cwd <path>` would do today. | |
| 859 | 849 | if !args.NoSess { | |
| 860 | - | core.PruneEmptySessions(ZotHome(), absPath) | |
| 861 | - | newSess, serr := core.NewSession(ZotHome(), absPath, newProvider, newModel, version) | |
| 850 | + | newRoot := agentSessionsRoot(ZotHome(), args) | |
| 851 | + | core.PruneEmptySessions(newRoot, absPath) | |
| 852 | + | newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version) | |
| 862 | 853 | if serr != nil { | |
| 863 | 854 | return fmt.Errorf("open session in %s: %v", absPath, serr) | |
| 864 | 855 | } | |
| @@ -1149,6 +1140,13 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1149 | 1140 | return runErr | |
| 1150 | 1141 | } | |
| 1151 | 1142 | ||
| 1143 | + | func agentSessionsRoot(root string, args Args) string { | |
| 1144 | + | if args.AgentName == "" { | |
| 1145 | + | return root | |
| 1146 | + | } | |
| 1147 | + | return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName)) | |
| 1148 | + | } | |
| 1149 | + | ||
| 1152 | 1150 | // openOrCreateSession returns a session for the run. sess may be nil | |
| 1153 | 1151 | // with a nil error if session persistence is disabled. | |
| 1154 | 1152 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| @@ -1158,7 +1156,8 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 1158 | 1156 | // Sweep meta-only files left over from older zot versions (and from | |
| 1159 | 1157 | // any session that crashed before its first AppendMessage). Cheap; | |
| 1160 | 1158 | // reads the first few bytes of each file in the cwd's session dir. | |
| 1161 | - | core.PruneEmptySessions(ZotHome(), args.CWD) | |
| 1159 | + | sessionsRoot := agentSessionsRoot(ZotHome(), args) | |
| 1160 | + | core.PruneEmptySessions(sessionsRoot, args.CWD) | |
| 1162 | 1161 | var ( | |
| 1163 | 1162 | s *core.Session | |
| 1164 | 1163 | msgs []provider.Message | |
| @@ -1180,12 +1179,12 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 1180 | 1179 | msgs = nil | |
| 1181 | 1180 | } | |
| 1182 | 1181 | case args.Continue: | |
| 1183 | - | latest := core.LatestSession(ZotHome(), args.CWD) | |
| 1182 | + | latest := core.LatestSession(sessionsRoot, args.CWD) | |
| 1184 | 1183 | if latest != "" { | |
| 1185 | 1184 | s, msgs, err = core.OpenSession(latest) | |
| 1186 | 1185 | } | |
| 1187 | 1186 | case args.Resume: | |
| 1188 | - | picked, perr := pickSession(args.CWD) | |
| 1187 | + | picked, perr := pickSession(sessionsRoot, args.CWD) | |
| 1189 | 1188 | if perr != nil { | |
| 1190 | 1189 | return nil, perr | |
| 1191 | 1190 | } | |
| @@ -1204,11 +1203,11 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 1204 | 1203 | } | |
| 1205 | 1204 | return s, nil | |
| 1206 | 1205 | } | |
| 1207 | - | return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version) | |
| 1206 | + | return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version) | |
| 1208 | 1207 | } | |
| 1209 | 1208 | ||
| 1210 | - | func pickSession(cwd string) (string, error) { | |
| 1211 | - | files := core.ListSessions(ZotHome(), cwd) | |
| 1209 | + | func pickSession(root, cwd string) (string, error) { | |
| 1210 | + | files := core.ListSessions(root, cwd) | |
| 1212 | 1211 | if len(files) == 0 { | |
| 1213 | 1212 | fmt.Fprintln(os.Stderr, "no sessions for", cwd) | |
| 1214 | 1213 | return "", nil | |
| @@ -189,6 +189,13 @@ func Run(rawArgs []string, version string) error { | |||||
| 189 | if handled, err := runUpdateCommand(rawArgs, version); handled { | 189 | if handled, err := runUpdateCommand(rawArgs, version); handled { | ||
| 190 | return err | 190 | return err | ||
| 191 | } | 191 | } | ||
| 192 | + | if handled, err := runZotfileCommand(rawArgs, version); handled { | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 195 | + | return runWithArgsRaw(rawArgs, version) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func runWithArgsRaw(rawArgs []string, version string) error { | |||
| 192 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | 199 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | ||
| 193 | // spawn the binary with a clean argv. Strip the leading 'rpc' | 200 | // spawn the binary with a clean argv. Strip the leading 'rpc' | ||
| 194 | // token and let the rest flow through the normal arg parser. | 201 | // token and let the rest flow through the normal arg parser. | ||
| @@ -211,39 +218,18 @@ func Run(rawArgs []string, version string) error { | |||||
| 211 | } | 218 | } | ||
| 212 | // Model catalog: load any cached discovery data before we inspect | 219 | // Model catalog: load any cached discovery data before we inspect | ||
| 213 | // the model list (list-models, print/json, interactive). | 220 | // the model list (list-models, print/json, interactive). | ||
| 214 | - | LoadCachedModels() | 221 | + | prepareRuntimeCatalog() |
| 215 | - | LoadUserModels() | |||
| 216 | - | ||||
| 217 | - | // Register custom provider names with the auth package so they | |||
| 218 | - | // participate in /login API-key flow and provider pickers. | |||
| 219 | - | if cps := provider.CustomProviders(); len(cps) > 0 { | |||
| 220 | - | names := make([]string, 0, len(cps)) | |||
| 221 | - | for name := range cps { | |||
| 222 | - | if !isBuiltinProvider(name) { | |||
| 223 | - | names = append(names, name) | |||
| 224 | - | } | |||
| 225 | - | } | |||
| 226 | - | auth.SetExtraAPIKeyProviders(names) | |||
| 227 | - | } | |||
| 228 | - | ||||
| 229 | - | // Repair config.json so a stale (provider, model) pair from an | |||
| 230 | - | // interrupted /model switch can't strand the user with an | |||
| 231 | - | // "unknown model" error on the first turn. Runs before any UI | |||
| 232 | - | // renders so the status bar shows the post-repair pair, not the | |||
| 233 | - | // broken one. Silent on success. | |||
| 234 | - | ValidateAndRepairConfig() | |||
| 235 | 222 | ||||
| 236 | if args.ListModels { | 223 | if args.ListModels { | ||
| 237 | printModels() | 224 | printModels() | ||
| 238 | return nil | 225 | return nil | ||
| 239 | } | 226 | } | ||
| 240 | 227 | ||||
| 241 | - | ctx := context.Background() | 228 | + | return runWithArgs(args, version) |
| 242 | - | 229 | + | } | |
| 243 | - | // Kick an async refresh of the live model catalog. The first run of | |||
| 244 | - | // zot hits the network; subsequent runs within CacheTTL do nothing. | |||
| 245 | - | RefreshModelsAsync() | |||
| 246 | 230 | ||||
| 231 | + | func runWithArgs(args Args, version string) error { | |||
| 232 | + | ctx := context.Background() | |||
| 247 | switch args.Mode { | 233 | switch args.Mode { | ||
| 248 | case ModePrint: | 234 | case ModePrint: | ||
| 249 | return runPrintMode(ctx, args, version) | 235 | return runPrintMode(ctx, args, version) | ||
| @@ -835,6 +821,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 835 | wasJailed := sharedSandbox != nil && sharedSandbox.Locked() | 821 | wasJailed := sharedSandbox != nil && sharedSandbox.Locked() | ||
| 836 | args.CWD = absPath | 822 | args.CWD = absPath | ||
| 837 | r.CWD = absPath | 823 | r.CWD = absPath | ||
| 824 | + | if args.PermissionSet != nil { | |||
| 825 | + | expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir) | |||
| 826 | + | args.PermissionSet = &expanded | |||
| 827 | + | } | |||
| 838 | if sharedSandbox != nil { | 828 | if sharedSandbox != nil { | ||
| 839 | sharedSandbox.Root = absPath | 829 | sharedSandbox.Root = absPath | ||
| 840 | if wasJailed { | 830 | if wasJailed { | ||
| @@ -857,8 +847,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 857 | // because /cd's semantics are "start fresh here", matching | 847 | // because /cd's semantics are "start fresh here", matching | ||
| 858 | // what relaunching `zot --cwd <path>` would do today. | 848 | // what relaunching `zot --cwd <path>` would do today. | ||
| 859 | if !args.NoSess { | 849 | if !args.NoSess { | ||
| 860 | - | core.PruneEmptySessions(ZotHome(), absPath) | 850 | + | newRoot := agentSessionsRoot(ZotHome(), args) |
| 861 | - | newSess, serr := core.NewSession(ZotHome(), absPath, newProvider, newModel, version) | 851 | + | core.PruneEmptySessions(newRoot, absPath) |
| 852 | + | newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version) | |||
| 862 | if serr != nil { | 853 | if serr != nil { | ||
| 863 | return fmt.Errorf("open session in %s: %v", absPath, serr) | 854 | return fmt.Errorf("open session in %s: %v", absPath, serr) | ||
| 864 | } | 855 | } | ||
| @@ -1149,6 +1140,13 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1149 | return runErr | 1140 | return runErr | ||
| 1150 | } | 1141 | } | ||
| 1151 | 1142 | ||||
| 1143 | + | func agentSessionsRoot(root string, args Args) string { | |||
| 1144 | + | if args.AgentName == "" { | |||
| 1145 | + | return root | |||
| 1146 | + | } | |||
| 1147 | + | return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName)) | |||
| 1148 | + | } | |||
| 1149 | + | ||||
| 1152 | // openOrCreateSession returns a session for the run. sess may be nil | 1150 | // openOrCreateSession returns a session for the run. sess may be nil | ||
| 1153 | // with a nil error if session persistence is disabled. | 1151 | // with a nil error if session persistence is disabled. | ||
| 1154 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | 1152 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | ||
| @@ -1158,7 +1156,8 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 1158 | // Sweep meta-only files left over from older zot versions (and from | 1156 | // Sweep meta-only files left over from older zot versions (and from | ||
| 1159 | // any session that crashed before its first AppendMessage). Cheap; | 1157 | // any session that crashed before its first AppendMessage). Cheap; | ||
| 1160 | // reads the first few bytes of each file in the cwd's session dir. | 1158 | // reads the first few bytes of each file in the cwd's session dir. | ||
| 1161 | - | core.PruneEmptySessions(ZotHome(), args.CWD) | 1159 | + | sessionsRoot := agentSessionsRoot(ZotHome(), args) |
| 1160 | + | core.PruneEmptySessions(sessionsRoot, args.CWD) | |||
| 1162 | var ( | 1161 | var ( | ||
| 1163 | s *core.Session | 1162 | s *core.Session | ||
| 1164 | msgs []provider.Message | 1163 | msgs []provider.Message | ||
| @@ -1180,12 +1179,12 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 1180 | msgs = nil | 1179 | msgs = nil | ||
| 1181 | } | 1180 | } | ||
| 1182 | case args.Continue: | 1181 | case args.Continue: | ||
| 1183 | - | latest := core.LatestSession(ZotHome(), args.CWD) | 1182 | + | latest := core.LatestSession(sessionsRoot, args.CWD) |
| 1184 | if latest != "" { | 1183 | if latest != "" { | ||
| 1185 | s, msgs, err = core.OpenSession(latest) | 1184 | s, msgs, err = core.OpenSession(latest) | ||
| 1186 | } | 1185 | } | ||
| 1187 | case args.Resume: | 1186 | case args.Resume: | ||
| 1188 | - | picked, perr := pickSession(args.CWD) | 1187 | + | picked, perr := pickSession(sessionsRoot, args.CWD) |
| 1189 | if perr != nil { | 1188 | if perr != nil { | ||
| 1190 | return nil, perr | 1189 | return nil, perr | ||
| 1191 | } | 1190 | } | ||
| @@ -1204,11 +1203,11 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 1204 | } | 1203 | } | ||
| 1205 | return s, nil | 1204 | return s, nil | ||
| 1206 | } | 1205 | } | ||
| 1207 | - | return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version) | 1206 | + | return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version) |
| 1208 | } | 1207 | } | ||
| 1209 | 1208 | ||||
| 1210 | - | func pickSession(cwd string) (string, error) { | 1209 | + | func pickSession(root, cwd string) (string, error) { |
| 1211 | - | files := core.ListSessions(ZotHome(), cwd) | 1210 | + | files := core.ListSessions(root, cwd) |
| 1212 | if len(files) == 0 { | 1211 | if len(files) == 0 { | ||
| 1213 | fmt.Fprintln(os.Stderr, "no sessions for", cwd) | 1212 | fmt.Fprintln(os.Stderr, "no sessions for", cwd) | ||
| 1214 | return "", nil | 1213 | return "", nil | ||
M packages/agent/skills/skills.go +5 -0
| @@ -230,6 +230,11 @@ func searchDirs(zotHome, cwd, userHome string) []location { | |||
| 230 | 230 | } | |
| 231 | 231 | out = append(out, location{dir: dir, label: label}) | |
| 232 | 232 | } | |
| 233 | + | if extra := os.Getenv("ZOT_AGENT_SKILLS"); extra != "" { | |
| 234 | + | for _, dir := range filepath.SplitList(extra) { | |
| 235 | + | add(dir, "agent") | |
| 236 | + | } | |
| 237 | + | } | |
| 233 | 238 | if cwd != "" { | |
| 234 | 239 | add(filepath.Join(cwd, ".zot", "skills"), "project") | |
| 235 | 240 | } | |
| @@ -230,6 +230,11 @@ func searchDirs(zotHome, cwd, userHome string) []location { | |||||
| 230 | } | 230 | } | ||
| 231 | out = append(out, location{dir: dir, label: label}) | 231 | out = append(out, location{dir: dir, label: label}) | ||
| 232 | } | 232 | } | ||
| 233 | + | if extra := os.Getenv("ZOT_AGENT_SKILLS"); extra != "" { | |||
| 234 | + | for _, dir := range filepath.SplitList(extra) { | |||
| 235 | + | add(dir, "agent") | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 233 | if cwd != "" { | 238 | if cwd != "" { | ||
| 234 | add(filepath.Join(cwd, ".zot", "skills"), "project") | 239 | add(filepath.Join(cwd, ".zot", "skills"), "project") | ||
| 235 | } | 240 | } | ||
M packages/agent/tools/bash.go +3 -0
| @@ -52,6 +52,9 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 52 | 52 | if err := t.Sandbox.CheckCommand(a.Command); err != nil { | |
| 53 | 53 | return core.ToolResult{}, err | |
| 54 | 54 | } | |
| 55 | + | if err := t.Sandbox.CheckBashPermission(a.Command); err != nil { | |
| 56 | + | return core.ToolResult{}, err | |
| 57 | + | } | |
| 55 | 58 | cwd := t.CWD | |
| 56 | 59 | if cwd == "" { | |
| 57 | 60 | cwd, _ = os.Getwd() | |
| @@ -52,6 +52,9 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 52 | if err := t.Sandbox.CheckCommand(a.Command); err != nil { | 52 | if err := t.Sandbox.CheckCommand(a.Command); err != nil { | ||
| 53 | return core.ToolResult{}, err | 53 | return core.ToolResult{}, err | ||
| 54 | } | 54 | } | ||
| 55 | + | if err := t.Sandbox.CheckBashPermission(a.Command); err != nil { | |||
| 56 | + | return core.ToolResult{}, err | |||
| 57 | + | } | |||
| 55 | cwd := t.CWD | 58 | cwd := t.CWD | ||
| 56 | if cwd == "" { | 59 | if cwd == "" { | ||
| 57 | cwd, _ = os.Getwd() | 60 | cwd, _ = os.Getwd() | ||
M packages/agent/tools/edit.go +1 -1
| @@ -48,7 +48,7 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 48 | 48 | return core.ToolResult{}, fmt.Errorf("at least one edit is required") | |
| 49 | 49 | } | |
| 50 | 50 | path := resolvePath(t.CWD, a.Path) | |
| 51 | - | if err := t.Sandbox.CheckPath(path); err != nil { | |
| 51 | + | if err := t.Sandbox.CheckWritePath(path); err != nil { | |
| 52 | 52 | return core.ToolResult{}, err | |
| 53 | 53 | } | |
| 54 | 54 | ||
| @@ -48,7 +48,7 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 48 | return core.ToolResult{}, fmt.Errorf("at least one edit is required") | 48 | return core.ToolResult{}, fmt.Errorf("at least one edit is required") | ||
| 49 | } | 49 | } | ||
| 50 | path := resolvePath(t.CWD, a.Path) | 50 | path := resolvePath(t.CWD, a.Path) | ||
| 51 | - | if err := t.Sandbox.CheckPath(path); err != nil { | 51 | + | if err := t.Sandbox.CheckWritePath(path); err != nil { |
| 52 | return core.ToolResult{}, err | 52 | return core.ToolResult{}, err | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
A packages/agent/tools/permissions.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // PermissionSet is the local-runtime permission contract for a packaged .zot agent. | |
| 10 | + | type PermissionSet struct { | |
| 11 | + | FS struct { | |
| 12 | + | Read []string `json:"read"` | |
| 13 | + | Write []string `json:"write"` | |
| 14 | + | } `json:"fs"` | |
| 15 | + | Bash struct { | |
| 16 | + | Mode string `json:"mode"` | |
| 17 | + | Allow []string `json:"allow"` | |
| 18 | + | } `json:"bash"` | |
| 19 | + | Net struct { | |
| 20 | + | Allow []string `json:"allow"` | |
| 21 | + | } `json:"net"` | |
| 22 | + | Env struct { | |
| 23 | + | Read []string `json:"read"` | |
| 24 | + | } `json:"env"` | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Expand resolves manifest variables into absolute local paths. | |
| 28 | + | func (p PermissionSet) Expand(workspace, agentData string) PermissionSet { | |
| 29 | + | expand := func(s string) string { | |
| 30 | + | s = strings.ReplaceAll(s, "${workspace}", workspace) | |
| 31 | + | s = strings.ReplaceAll(s, "${agent_data}", agentData) | |
| 32 | + | if !filepath.IsAbs(s) && s != "" { | |
| 33 | + | s = filepath.Join(workspace, s) | |
| 34 | + | } | |
| 35 | + | return s | |
| 36 | + | } | |
| 37 | + | // Clone the slices before expanding them. PermissionSet is copied by value, | |
| 38 | + | // but slices otherwise still alias the manifest and repeated expansion (for | |
| 39 | + | // example after /cd) would mutate the original declaration. | |
| 40 | + | p.FS.Read = append([]string(nil), p.FS.Read...) | |
| 41 | + | p.FS.Write = append([]string(nil), p.FS.Write...) | |
| 42 | + | for i, v := range p.FS.Read { | |
| 43 | + | p.FS.Read[i] = expand(v) | |
| 44 | + | } | |
| 45 | + | for i, v := range p.FS.Write { | |
| 46 | + | p.FS.Write[i] = expand(v) | |
| 47 | + | } | |
| 48 | + | return p | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (s *Sandbox) SetPermissions(p *PermissionSet) { | |
| 52 | + | if s == nil { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | s.Permissions = p | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (s *Sandbox) CheckReadPath(path string) error { | |
| 59 | + | if err := s.CheckPath(path); err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | if s == nil || s.Permissions == nil { | |
| 63 | + | return nil | |
| 64 | + | } | |
| 65 | + | if len(s.Permissions.FS.Read) == 0 { | |
| 66 | + | return fmt.Errorf("permission denied: this agent has no filesystem read permission") | |
| 67 | + | } | |
| 68 | + | return checkScopedPath("read", path, s.Permissions.FS.Read) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (s *Sandbox) CheckWritePath(path string) error { | |
| 72 | + | if err := s.CheckPath(path); err != nil { | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | if s == nil || s.Permissions == nil { | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | if len(s.Permissions.FS.Write) == 0 { | |
| 79 | + | return fmt.Errorf("permission denied: this agent has no filesystem write permission") | |
| 80 | + | } | |
| 81 | + | return checkScopedPath("write", path, s.Permissions.FS.Write) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func checkScopedPath(op, path string, scopes []string) error { | |
| 85 | + | target, err := canonicalOrParent(path) | |
| 86 | + | if err != nil { | |
| 87 | + | return fmt.Errorf("permission path: %w", err) | |
| 88 | + | } | |
| 89 | + | for _, scope := range scopes { | |
| 90 | + | root, err := canonicalOrParent(scope) | |
| 91 | + | if err != nil { | |
| 92 | + | continue | |
| 93 | + | } | |
| 94 | + | if isUnder(root, target) { | |
| 95 | + | return nil | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (s *Sandbox) CheckBashPermission(cmd string) error { | |
| 102 | + | if s == nil || s.Permissions == nil { | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | mode := strings.ToLower(strings.TrimSpace(s.Permissions.Bash.Mode)) | |
| 106 | + | if mode == "" { | |
| 107 | + | mode = "none" | |
| 108 | + | } | |
| 109 | + | switch mode { | |
| 110 | + | case "none": | |
| 111 | + | return fmt.Errorf("permission denied: this agent has no bash permission") | |
| 112 | + | case "ask": | |
| 113 | + | // The local runtime obtains explicit consent for this capability at | |
| 114 | + | // every launch. Unlike durable consent, ask mode is never cached. | |
| 115 | + | return nil | |
| 116 | + | case "allowlist": | |
| 117 | + | if strings.ContainsAny(cmd, "`\n\r<>") || strings.Contains(cmd, "$(") { | |
| 118 | + | return fmt.Errorf("permission denied: bash allowlist does not permit substitution or redirection") | |
| 119 | + | } | |
| 120 | + | names := commandNames(cmd) | |
| 121 | + | if len(names) == 0 { | |
| 122 | + | return fmt.Errorf("permission denied: empty command") | |
| 123 | + | } | |
| 124 | + | allowed := map[string]bool{} | |
| 125 | + | for _, name := range s.Permissions.Bash.Allow { | |
| 126 | + | allowed[filepath.Base(strings.TrimSpace(name))] = true | |
| 127 | + | } | |
| 128 | + | for _, name := range names { | |
| 129 | + | if !allowed[name] { | |
| 130 | + | return fmt.Errorf("permission denied: bash command %q is not in allowlist", name) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | return nil | |
| 134 | + | default: | |
| 135 | + | return fmt.Errorf("permission denied: unsupported bash mode %q", mode) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func commandNames(cmd string) []string { | |
| 140 | + | cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd) | |
| 141 | + | parts := strings.Split(cmd, ";") | |
| 142 | + | var out []string | |
| 143 | + | for _, part := range parts { | |
| 144 | + | name := firstCommandName(part) | |
| 145 | + | if name == "" || name == "cd" { | |
| 146 | + | continue | |
| 147 | + | } | |
| 148 | + | out = append(out, name) | |
| 149 | + | } | |
| 150 | + | return out | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func firstCommandName(cmd string) string { | |
| 154 | + | cmd = strings.TrimSpace(cmd) | |
| 155 | + | if cmd == "" { | |
| 156 | + | return "" | |
| 157 | + | } | |
| 158 | + | fields := strings.Fields(cmd) | |
| 159 | + | if len(fields) == 0 { | |
| 160 | + | return "" | |
| 161 | + | } | |
| 162 | + | name := fields[0] | |
| 163 | + | for strings.Contains(name, "=") && len(fields) > 1 { | |
| 164 | + | fields = fields[1:] | |
| 165 | + | name = fields[0] | |
| 166 | + | } | |
| 167 | + | return filepath.Base(strings.Trim(name, "'\"")) | |
| 168 | + | } | |
| @@ -0,0 +1,168 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // PermissionSet is the local-runtime permission contract for a packaged .zot agent. | |||
| 10 | + | type PermissionSet struct { | |||
| 11 | + | FS struct { | |||
| 12 | + | Read []string `json:"read"` | |||
| 13 | + | Write []string `json:"write"` | |||
| 14 | + | } `json:"fs"` | |||
| 15 | + | Bash struct { | |||
| 16 | + | Mode string `json:"mode"` | |||
| 17 | + | Allow []string `json:"allow"` | |||
| 18 | + | } `json:"bash"` | |||
| 19 | + | Net struct { | |||
| 20 | + | Allow []string `json:"allow"` | |||
| 21 | + | } `json:"net"` | |||
| 22 | + | Env struct { | |||
| 23 | + | Read []string `json:"read"` | |||
| 24 | + | } `json:"env"` | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // Expand resolves manifest variables into absolute local paths. | |||
| 28 | + | func (p PermissionSet) Expand(workspace, agentData string) PermissionSet { | |||
| 29 | + | expand := func(s string) string { | |||
| 30 | + | s = strings.ReplaceAll(s, "${workspace}", workspace) | |||
| 31 | + | s = strings.ReplaceAll(s, "${agent_data}", agentData) | |||
| 32 | + | if !filepath.IsAbs(s) && s != "" { | |||
| 33 | + | s = filepath.Join(workspace, s) | |||
| 34 | + | } | |||
| 35 | + | return s | |||
| 36 | + | } | |||
| 37 | + | // Clone the slices before expanding them. PermissionSet is copied by value, | |||
| 38 | + | // but slices otherwise still alias the manifest and repeated expansion (for | |||
| 39 | + | // example after /cd) would mutate the original declaration. | |||
| 40 | + | p.FS.Read = append([]string(nil), p.FS.Read...) | |||
| 41 | + | p.FS.Write = append([]string(nil), p.FS.Write...) | |||
| 42 | + | for i, v := range p.FS.Read { | |||
| 43 | + | p.FS.Read[i] = expand(v) | |||
| 44 | + | } | |||
| 45 | + | for i, v := range p.FS.Write { | |||
| 46 | + | p.FS.Write[i] = expand(v) | |||
| 47 | + | } | |||
| 48 | + | return p | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (s *Sandbox) SetPermissions(p *PermissionSet) { | |||
| 52 | + | if s == nil { | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | s.Permissions = p | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (s *Sandbox) CheckReadPath(path string) error { | |||
| 59 | + | if err := s.CheckPath(path); err != nil { | |||
| 60 | + | return err | |||
| 61 | + | } | |||
| 62 | + | if s == nil || s.Permissions == nil { | |||
| 63 | + | return nil | |||
| 64 | + | } | |||
| 65 | + | if len(s.Permissions.FS.Read) == 0 { | |||
| 66 | + | return fmt.Errorf("permission denied: this agent has no filesystem read permission") | |||
| 67 | + | } | |||
| 68 | + | return checkScopedPath("read", path, s.Permissions.FS.Read) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (s *Sandbox) CheckWritePath(path string) error { | |||
| 72 | + | if err := s.CheckPath(path); err != nil { | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 75 | + | if s == nil || s.Permissions == nil { | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | if len(s.Permissions.FS.Write) == 0 { | |||
| 79 | + | return fmt.Errorf("permission denied: this agent has no filesystem write permission") | |||
| 80 | + | } | |||
| 81 | + | return checkScopedPath("write", path, s.Permissions.FS.Write) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func checkScopedPath(op, path string, scopes []string) error { | |||
| 85 | + | target, err := canonicalOrParent(path) | |||
| 86 | + | if err != nil { | |||
| 87 | + | return fmt.Errorf("permission path: %w", err) | |||
| 88 | + | } | |||
| 89 | + | for _, scope := range scopes { | |||
| 90 | + | root, err := canonicalOrParent(scope) | |||
| 91 | + | if err != nil { | |||
| 92 | + | continue | |||
| 93 | + | } | |||
| 94 | + | if isUnder(root, target) { | |||
| 95 | + | return nil | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (s *Sandbox) CheckBashPermission(cmd string) error { | |||
| 102 | + | if s == nil || s.Permissions == nil { | |||
| 103 | + | return nil | |||
| 104 | + | } | |||
| 105 | + | mode := strings.ToLower(strings.TrimSpace(s.Permissions.Bash.Mode)) | |||
| 106 | + | if mode == "" { | |||
| 107 | + | mode = "none" | |||
| 108 | + | } | |||
| 109 | + | switch mode { | |||
| 110 | + | case "none": | |||
| 111 | + | return fmt.Errorf("permission denied: this agent has no bash permission") | |||
| 112 | + | case "ask": | |||
| 113 | + | // The local runtime obtains explicit consent for this capability at | |||
| 114 | + | // every launch. Unlike durable consent, ask mode is never cached. | |||
| 115 | + | return nil | |||
| 116 | + | case "allowlist": | |||
| 117 | + | if strings.ContainsAny(cmd, "`\n\r<>") || strings.Contains(cmd, "$(") { | |||
| 118 | + | return fmt.Errorf("permission denied: bash allowlist does not permit substitution or redirection") | |||
| 119 | + | } | |||
| 120 | + | names := commandNames(cmd) | |||
| 121 | + | if len(names) == 0 { | |||
| 122 | + | return fmt.Errorf("permission denied: empty command") | |||
| 123 | + | } | |||
| 124 | + | allowed := map[string]bool{} | |||
| 125 | + | for _, name := range s.Permissions.Bash.Allow { | |||
| 126 | + | allowed[filepath.Base(strings.TrimSpace(name))] = true | |||
| 127 | + | } | |||
| 128 | + | for _, name := range names { | |||
| 129 | + | if !allowed[name] { | |||
| 130 | + | return fmt.Errorf("permission denied: bash command %q is not in allowlist", name) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | return nil | |||
| 134 | + | default: | |||
| 135 | + | return fmt.Errorf("permission denied: unsupported bash mode %q", mode) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func commandNames(cmd string) []string { | |||
| 140 | + | cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd) | |||
| 141 | + | parts := strings.Split(cmd, ";") | |||
| 142 | + | var out []string | |||
| 143 | + | for _, part := range parts { | |||
| 144 | + | name := firstCommandName(part) | |||
| 145 | + | if name == "" || name == "cd" { | |||
| 146 | + | continue | |||
| 147 | + | } | |||
| 148 | + | out = append(out, name) | |||
| 149 | + | } | |||
| 150 | + | return out | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func firstCommandName(cmd string) string { | |||
| 154 | + | cmd = strings.TrimSpace(cmd) | |||
| 155 | + | if cmd == "" { | |||
| 156 | + | return "" | |||
| 157 | + | } | |||
| 158 | + | fields := strings.Fields(cmd) | |||
| 159 | + | if len(fields) == 0 { | |||
| 160 | + | return "" | |||
| 161 | + | } | |||
| 162 | + | name := fields[0] | |||
| 163 | + | for strings.Contains(name, "=") && len(fields) > 1 { | |||
| 164 | + | fields = fields[1:] | |||
| 165 | + | name = fields[0] | |||
| 166 | + | } | |||
| 167 | + | return filepath.Base(strings.Trim(name, "'\"")) | |||
| 168 | + | } | |||
A packages/agent/tools/permissions_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPermissionSetEmptyScopesDenyAccess(t *testing.T) { | |
| 10 | + | s := NewSandbox(t.TempDir()) | |
| 11 | + | s.SetPermissions(&PermissionSet{}) | |
| 12 | + | if err := s.CheckReadPath(filepath.Join(s.Root, "a")); err == nil { | |
| 13 | + | t.Fatal("empty read scope allowed access") | |
| 14 | + | } | |
| 15 | + | if err := s.CheckWritePath(filepath.Join(s.Root, "a")); err == nil { | |
| 16 | + | t.Fatal("empty write scope allowed access") | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestPermissionSetScopedAccess(t *testing.T) { | |
| 21 | + | root := t.TempDir() | |
| 22 | + | var p PermissionSet | |
| 23 | + | p.FS.Read = []string{"${workspace}/src"} | |
| 24 | + | p.FS.Write = []string{"${agent_data}"} | |
| 25 | + | expanded := p.Expand(root, filepath.Join(root, "data")) | |
| 26 | + | s := NewSandbox(root) | |
| 27 | + | s.SetPermissions(&expanded) | |
| 28 | + | if err := s.CheckReadPath(filepath.Join(root, "src", "a.go")); err != nil { | |
| 29 | + | t.Fatalf("read inside scope: %v", err) | |
| 30 | + | } | |
| 31 | + | if err := s.CheckReadPath(filepath.Join(root, "secret")); err == nil { | |
| 32 | + | t.Fatal("read outside scope was allowed") | |
| 33 | + | } | |
| 34 | + | if err := s.CheckWritePath(filepath.Join(root, "data", "state.json")); err != nil { | |
| 35 | + | t.Fatalf("write inside scope: %v", err) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestPermissionExpandDoesNotMutateManifest(t *testing.T) { | |
| 40 | + | var p PermissionSet | |
| 41 | + | p.FS.Read = []string{"${workspace}"} | |
| 42 | + | _ = p.Expand("/first", "/data") | |
| 43 | + | if got := p.FS.Read[0]; got != "${workspace}" { | |
| 44 | + | t.Fatalf("manifest mutated to %q", got) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestBashAllowlistRejectsShellEscapes(t *testing.T) { | |
| 49 | + | var p PermissionSet | |
| 50 | + | p.Bash.Mode = "allowlist" | |
| 51 | + | p.Bash.Allow = []string{"echo"} | |
| 52 | + | s := NewSandbox(t.TempDir()) | |
| 53 | + | s.SetPermissions(&p) | |
| 54 | + | for _, cmd := range []string{"echo $(uname)", "echo hi > /tmp/out", "echo `uname`", "echo ok\nrm -rf /"} { | |
| 55 | + | if err := s.CheckBashPermission(cmd); err == nil || !strings.Contains(err.Error(), "does not permit") { | |
| 56 | + | t.Errorf("command %q was not rejected: %v", cmd, err) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | if err := s.CheckBashPermission("echo ok"); err != nil { | |
| 60 | + | t.Fatalf("allowed command rejected: %v", err) | |
| 61 | + | } | |
| 62 | + | if err := s.CheckBashPermission("rm file"); err == nil { | |
| 63 | + | t.Fatal("unlisted command was allowed") | |
| 64 | + | } | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestPermissionSetEmptyScopesDenyAccess(t *testing.T) { | |||
| 10 | + | s := NewSandbox(t.TempDir()) | |||
| 11 | + | s.SetPermissions(&PermissionSet{}) | |||
| 12 | + | if err := s.CheckReadPath(filepath.Join(s.Root, "a")); err == nil { | |||
| 13 | + | t.Fatal("empty read scope allowed access") | |||
| 14 | + | } | |||
| 15 | + | if err := s.CheckWritePath(filepath.Join(s.Root, "a")); err == nil { | |||
| 16 | + | t.Fatal("empty write scope allowed access") | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestPermissionSetScopedAccess(t *testing.T) { | |||
| 21 | + | root := t.TempDir() | |||
| 22 | + | var p PermissionSet | |||
| 23 | + | p.FS.Read = []string{"${workspace}/src"} | |||
| 24 | + | p.FS.Write = []string{"${agent_data}"} | |||
| 25 | + | expanded := p.Expand(root, filepath.Join(root, "data")) | |||
| 26 | + | s := NewSandbox(root) | |||
| 27 | + | s.SetPermissions(&expanded) | |||
| 28 | + | if err := s.CheckReadPath(filepath.Join(root, "src", "a.go")); err != nil { | |||
| 29 | + | t.Fatalf("read inside scope: %v", err) | |||
| 30 | + | } | |||
| 31 | + | if err := s.CheckReadPath(filepath.Join(root, "secret")); err == nil { | |||
| 32 | + | t.Fatal("read outside scope was allowed") | |||
| 33 | + | } | |||
| 34 | + | if err := s.CheckWritePath(filepath.Join(root, "data", "state.json")); err != nil { | |||
| 35 | + | t.Fatalf("write inside scope: %v", err) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestPermissionExpandDoesNotMutateManifest(t *testing.T) { | |||
| 40 | + | var p PermissionSet | |||
| 41 | + | p.FS.Read = []string{"${workspace}"} | |||
| 42 | + | _ = p.Expand("/first", "/data") | |||
| 43 | + | if got := p.FS.Read[0]; got != "${workspace}" { | |||
| 44 | + | t.Fatalf("manifest mutated to %q", got) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestBashAllowlistRejectsShellEscapes(t *testing.T) { | |||
| 49 | + | var p PermissionSet | |||
| 50 | + | p.Bash.Mode = "allowlist" | |||
| 51 | + | p.Bash.Allow = []string{"echo"} | |||
| 52 | + | s := NewSandbox(t.TempDir()) | |||
| 53 | + | s.SetPermissions(&p) | |||
| 54 | + | for _, cmd := range []string{"echo $(uname)", "echo hi > /tmp/out", "echo `uname`", "echo ok\nrm -rf /"} { | |||
| 55 | + | if err := s.CheckBashPermission(cmd); err == nil || !strings.Contains(err.Error(), "does not permit") { | |||
| 56 | + | t.Errorf("command %q was not rejected: %v", cmd, err) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | if err := s.CheckBashPermission("echo ok"); err != nil { | |||
| 60 | + | t.Fatalf("allowed command rejected: %v", err) | |||
| 61 | + | } | |||
| 62 | + | if err := s.CheckBashPermission("rm file"); err == nil { | |||
| 63 | + | t.Fatal("unlisted command was allowed") | |||
| 64 | + | } | |||
| 65 | + | } | |||
M packages/agent/tools/read.go +1 -1
| @@ -49,7 +49,7 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 49 | 49 | return core.ToolResult{}, fmt.Errorf("path is required") | |
| 50 | 50 | } | |
| 51 | 51 | path := resolvePath(t.CWD, a.Path) | |
| 52 | - | if err := t.Sandbox.CheckPath(path); err != nil { | |
| 52 | + | if err := t.Sandbox.CheckReadPath(path); err != nil { | |
| 53 | 53 | return core.ToolResult{}, err | |
| 54 | 54 | } | |
| 55 | 55 | // What the model sees in errors: relative to the sandbox root when | |
| @@ -49,7 +49,7 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 49 | return core.ToolResult{}, fmt.Errorf("path is required") | 49 | return core.ToolResult{}, fmt.Errorf("path is required") | ||
| 50 | } | 50 | } | ||
| 51 | path := resolvePath(t.CWD, a.Path) | 51 | path := resolvePath(t.CWD, a.Path) | ||
| 52 | - | if err := t.Sandbox.CheckPath(path); err != nil { | 52 | + | if err := t.Sandbox.CheckReadPath(path); err != nil { |
| 53 | return core.ToolResult{}, err | 53 | return core.ToolResult{}, err | ||
| 54 | } | 54 | } | ||
| 55 | // What the model sees in errors: relative to the sandbox root when | 55 | // What the model sees in errors: relative to the sandbox root when | ||
M packages/agent/tools/sandbox.go +3 -2
| @@ -15,8 +15,9 @@ import ( | |||
| 15 | 15 | // The value is designed to be shared across tool instances (by pointer). | |
| 16 | 16 | // Enable/Disable are atomic so they can be toggled from the TUI. | |
| 17 | 17 | type Sandbox struct { | |
| 18 | - | Root string | |
| 19 | - | locked atomic.Bool | |
| 18 | + | Root string | |
| 19 | + | Permissions *PermissionSet | |
| 20 | + | locked atomic.Bool | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // NewSandbox returns a Sandbox rooted at cwd. It starts unlocked. | |
| @@ -15,8 +15,9 @@ import ( | |||||
| 15 | // The value is designed to be shared across tool instances (by pointer). | 15 | // The value is designed to be shared across tool instances (by pointer). | ||
| 16 | // Enable/Disable are atomic so they can be toggled from the TUI. | 16 | // Enable/Disable are atomic so they can be toggled from the TUI. | ||
| 17 | type Sandbox struct { | 17 | type Sandbox struct { | ||
| 18 | - | Root string | 18 | + | Root string |
| 19 | - | locked atomic.Bool | 19 | + | Permissions *PermissionSet |
| 20 | + | locked atomic.Bool | |||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | // NewSandbox returns a Sandbox rooted at cwd. It starts unlocked. | 23 | // NewSandbox returns a Sandbox rooted at cwd. It starts unlocked. | ||
M packages/agent/tools/write.go +1 -1
| @@ -40,7 +40,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f | |||
| 40 | 40 | return core.ToolResult{}, fmt.Errorf("path is required") | |
| 41 | 41 | } | |
| 42 | 42 | path := resolvePath(t.CWD, a.Path) | |
| 43 | - | if err := t.Sandbox.CheckPath(path); err != nil { | |
| 43 | + | if err := t.Sandbox.CheckWritePath(path); err != nil { | |
| 44 | 44 | return core.ToolResult{}, err | |
| 45 | 45 | } | |
| 46 | 46 | ||
| @@ -40,7 +40,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f | |||||
| 40 | return core.ToolResult{}, fmt.Errorf("path is required") | 40 | return core.ToolResult{}, fmt.Errorf("path is required") | ||
| 41 | } | 41 | } | ||
| 42 | path := resolvePath(t.CWD, a.Path) | 42 | path := resolvePath(t.CWD, a.Path) | ||
| 43 | - | if err := t.Sandbox.CheckPath(path); err != nil { | 43 | + | if err := t.Sandbox.CheckWritePath(path); err != nil { |
| 44 | return core.ToolResult{}, err | 44 | return core.ToolResult{}, err | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
A packages/agent/zotfile.go +813 -0
| @@ -0,0 +1,813 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "archive/tar" | |
| 5 | + | "bytes" | |
| 6 | + | "compress/gzip" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "encoding/hex" | |
| 9 | + | "encoding/json" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "net/http" | |
| 13 | + | "net/url" | |
| 14 | + | "os" | |
| 15 | + | "os/exec" | |
| 16 | + | "path/filepath" | |
| 17 | + | "runtime" | |
| 18 | + | "sort" | |
| 19 | + | "strings" | |
| 20 | + | "time" | |
| 21 | + | ||
| 22 | + | "github.com/klauspost/compress/zstd" | |
| 23 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 24 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 25 | + | "github.com/patriceckhart/zot/packages/provider/auth" | |
| 26 | + | "golang.org/x/term" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | type ZotfileManifest struct { | |
| 30 | + | Zotfile int `json:"zotfile"` | |
| 31 | + | Name string `json:"name"` | |
| 32 | + | Version string `json:"version"` | |
| 33 | + | Description string `json:"description"` | |
| 34 | + | License string `json:"license"` | |
| 35 | + | Runtime struct { | |
| 36 | + | MinZot string `json:"min_zot"` | |
| 37 | + | } `json:"runtime"` | |
| 38 | + | Model struct { | |
| 39 | + | Requires []string `json:"requires"` | |
| 40 | + | MinContext int `json:"min_context"` | |
| 41 | + | Preferred []string `json:"preferred"` | |
| 42 | + | MinTier string `json:"min_tier"` | |
| 43 | + | } `json:"model"` | |
| 44 | + | Permissions tools.PermissionSet `json:"permissions"` | |
| 45 | + | Requirements struct { | |
| 46 | + | Bin []string `json:"bin"` | |
| 47 | + | OS []string `json:"os"` | |
| 48 | + | } `json:"requirements"` | |
| 49 | + | Entry struct { | |
| 50 | + | Greeting string `json:"greeting"` | |
| 51 | + | DefaultPrompt *string `json:"default_prompt"` | |
| 52 | + | } `json:"entry"` | |
| 53 | + | ReplaceSystemPrompt bool `json:"replace_system_prompt"` | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | type zotfileLoaded struct { | |
| 57 | + | Dir string | |
| 58 | + | Temp bool | |
| 59 | + | Digest string | |
| 60 | + | Manifest ZotfileManifest | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func runZotfileCommand(rawArgs []string, version string) (bool, error) { | |
| 64 | + | if len(rawArgs) == 0 { | |
| 65 | + | return false, nil | |
| 66 | + | } | |
| 67 | + | switch rawArgs[0] { | |
| 68 | + | case "pack": | |
| 69 | + | dir := "." | |
| 70 | + | out := "" | |
| 71 | + | if len(rawArgs) > 1 { | |
| 72 | + | dir = rawArgs[1] | |
| 73 | + | } | |
| 74 | + | if len(rawArgs) > 2 { | |
| 75 | + | out = rawArgs[2] | |
| 76 | + | } | |
| 77 | + | return true, zotPack(dir, out) | |
| 78 | + | case "inspect": | |
| 79 | + | if len(rawArgs) < 2 { | |
| 80 | + | return true, fmt.Errorf("zot inspect requires a .zot file, directory, or GitHub URL") | |
| 81 | + | } | |
| 82 | + | return true, zotInspect(rawArgs[1]) | |
| 83 | + | case "verify": | |
| 84 | + | if len(rawArgs) < 2 { | |
| 85 | + | return true, fmt.Errorf("zot verify requires a .zot file, directory, or GitHub URL") | |
| 86 | + | } | |
| 87 | + | zf, cleanup, err := loadZotfile(rawArgs[1]) | |
| 88 | + | if cleanup != nil { | |
| 89 | + | defer cleanup() | |
| 90 | + | } | |
| 91 | + | if err != nil { | |
| 92 | + | return true, err | |
| 93 | + | } | |
| 94 | + | fmt.Printf("ok digest sha256:%s\n", zf.Digest) | |
| 95 | + | return true, nil | |
| 96 | + | case "run": | |
| 97 | + | if len(rawArgs) < 2 { | |
| 98 | + | return true, fmt.Errorf("zot run requires a .zot file, directory, or GitHub URL") | |
| 99 | + | } | |
| 100 | + | ref := rawArgs[1] | |
| 101 | + | rest := rawArgs[2:] | |
| 102 | + | args, err := ParseArgs(rest) | |
| 103 | + | if err != nil { | |
| 104 | + | PrintHelp(version) | |
| 105 | + | return true, err | |
| 106 | + | } | |
| 107 | + | return true, runLocalZotfile(ref, args, version) | |
| 108 | + | default: | |
| 109 | + | return false, nil | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func runLocalZotfile(ref string, args Args, version string) error { | |
| 114 | + | prepareRuntimeCatalog() | |
| 115 | + | zf, cleanup, err := loadZotfile(ref) | |
| 116 | + | if cleanup != nil { | |
| 117 | + | defer cleanup() | |
| 118 | + | } | |
| 119 | + | if err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 122 | + | if err := checkZotfileRequirements(zf, version); err != nil { | |
| 123 | + | return err | |
| 124 | + | } | |
| 125 | + | agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data") | |
| 126 | + | if err := os.MkdirAll(agentData, 0o755); err != nil { | |
| 127 | + | return err | |
| 128 | + | } | |
| 129 | + | perms := zf.Manifest.Permissions.Expand(args.CWD, agentData) | |
| 130 | + | if err := consentZotfile(zf, perms); err != nil { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | agentPath := filepath.Join(zf.Dir, "AGENT.md") | |
| 134 | + | agentPrompt, err := os.ReadFile(agentPath) | |
| 135 | + | if err != nil { | |
| 136 | + | return fmt.Errorf("read AGENT.md: %w", err) | |
| 137 | + | } | |
| 138 | + | if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil { | |
| 139 | + | return err | |
| 140 | + | } | |
| 141 | + | if zf.Manifest.ReplaceSystemPrompt { | |
| 142 | + | args.SystemPrompt = strings.TrimSpace(string(agentPrompt)) | |
| 143 | + | } else { | |
| 144 | + | args.AppendSystemPrompt = append(args.AppendSystemPrompt, strings.TrimSpace(string(agentPrompt))) | |
| 145 | + | } | |
| 146 | + | if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil { | |
| 147 | + | args.Prompt = *zf.Manifest.Entry.DefaultPrompt | |
| 148 | + | } | |
| 149 | + | if dirExists(filepath.Join(zf.Dir, "skills")) { | |
| 150 | + | old := os.Getenv("ZOT_AGENT_SKILLS") | |
| 151 | + | v := filepath.Join(zf.Dir, "skills") | |
| 152 | + | if old != "" { | |
| 153 | + | v += string(os.PathListSeparator) + old | |
| 154 | + | } | |
| 155 | + | _ = os.Setenv("ZOT_AGENT_SKILLS", v) | |
| 156 | + | defer os.Setenv("ZOT_AGENT_SKILLS", old) | |
| 157 | + | } | |
| 158 | + | args.AgentName = zf.Manifest.Name | |
| 159 | + | args.AgentDataDir = agentData | |
| 160 | + | args.PermissionSet = &perms | |
| 161 | + | return runWithArgs(args, version) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func zotInspect(ref string) error { | |
| 165 | + | zf, cleanup, err := loadZotfile(ref) | |
| 166 | + | if cleanup != nil { | |
| 167 | + | defer cleanup() | |
| 168 | + | } | |
| 169 | + | if err != nil { | |
| 170 | + | return err | |
| 171 | + | } | |
| 172 | + | m := zf.Manifest | |
| 173 | + | fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest) | |
| 174 | + | fmt.Println("\npermissions:") | |
| 175 | + | fmt.Print(permissionSummary(m.Permissions)) | |
| 176 | + | fmt.Println("\nfiles:") | |
| 177 | + | return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error { | |
| 178 | + | if err != nil || path == zf.Dir { | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | rel, _ := filepath.Rel(zf.Dir, path) | |
| 182 | + | if d.IsDir() { | |
| 183 | + | rel += "/" | |
| 184 | + | } | |
| 185 | + | fmt.Println(" " + filepath.ToSlash(rel)) | |
| 186 | + | return nil | |
| 187 | + | }) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error { | |
| 191 | + | minCtx := m.Model.MinContext | |
| 192 | + | requires := map[string]bool{} | |
| 193 | + | for _, capability := range m.Model.Requires { | |
| 194 | + | requires[strings.ToLower(strings.TrimSpace(capability))] = true | |
| 195 | + | } | |
| 196 | + | for capability := range requires { | |
| 197 | + | if capability != "tools" && capability != "vision" && capability != "reasoning" { | |
| 198 | + | return fmt.Errorf("unsupported model requirement %q", capability) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | if m.Model.MinTier != "" { | |
| 202 | + | return fmt.Errorf("model.min_tier is not supported by this zot version") | |
| 203 | + | } | |
| 204 | + | compatible := func(model provider.Model) bool { | |
| 205 | + | if model.ContextWindow < minCtx { | |
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | if requires["reasoning"] && !model.Reasoning { | |
| 209 | + | return false | |
| 210 | + | } | |
| 211 | + | // Every model exposed by the current catalog supports text and tools. | |
| 212 | + | // Vision support is not represented in Model yet, so fail closed. | |
| 213 | + | return !requires["vision"] | |
| 214 | + | } | |
| 215 | + | if minCtx <= 0 && len(requires) == 0 { | |
| 216 | + | return nil | |
| 217 | + | } | |
| 218 | + | if args.Model != "" { | |
| 219 | + | model, err := provider.FindModel(args.Provider, args.Model) | |
| 220 | + | if err != nil { | |
| 221 | + | model, err = provider.FindModel("", args.Model) | |
| 222 | + | } | |
| 223 | + | if err != nil { | |
| 224 | + | return nil | |
| 225 | + | } | |
| 226 | + | if !compatible(model) { | |
| 227 | + | return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID) | |
| 228 | + | } | |
| 229 | + | return nil | |
| 230 | + | } | |
| 231 | + | cfg, _ := LoadConfig() | |
| 232 | + | if cfg.Model != "" { | |
| 233 | + | model, err := provider.FindModel(cfg.Provider, cfg.Model) | |
| 234 | + | if err == nil && compatible(model) { | |
| 235 | + | return nil | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | for _, id := range m.Model.Preferred { | |
| 239 | + | model, err := provider.FindModel("", id) | |
| 240 | + | if err == nil && compatible(model) { | |
| 241 | + | args.Provider = model.Provider | |
| 242 | + | args.Model = model.ID | |
| 243 | + | return nil | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | for _, model := range provider.Active() { | |
| 247 | + | if compatible(model) { | |
| 248 | + | args.Provider = model.Provider | |
| 249 | + | args.Model = model.ID | |
| 250 | + | return nil | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | return fmt.Errorf("no catalog model satisfies the agent requirements") | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func prepareRuntimeCatalog() { | |
| 257 | + | LoadCachedModels() | |
| 258 | + | LoadUserModels() | |
| 259 | + | if cps := provider.CustomProviders(); len(cps) > 0 { | |
| 260 | + | var names []string | |
| 261 | + | for name := range cps { | |
| 262 | + | if !isBuiltinProvider(name) { | |
| 263 | + | names = append(names, name) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | auth.SetExtraAPIKeyProviders(names) | |
| 267 | + | } | |
| 268 | + | ValidateAndRepairConfig() | |
| 269 | + | RefreshModelsAsync() | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func zotPack(dir, out string) error { | |
| 273 | + | abs, err := filepath.Abs(dir) | |
| 274 | + | if err != nil { | |
| 275 | + | return err | |
| 276 | + | } | |
| 277 | + | if _, err := readZotManifest(abs); err != nil { | |
| 278 | + | return err | |
| 279 | + | } | |
| 280 | + | if err := validateZotfileDir(abs); err != nil { | |
| 281 | + | return err | |
| 282 | + | } | |
| 283 | + | if out == "" { | |
| 284 | + | m, _ := readZotManifest(abs) | |
| 285 | + | base := safeAgentName(m.Name) | |
| 286 | + | if base == "" { | |
| 287 | + | base = filepath.Base(abs) | |
| 288 | + | } | |
| 289 | + | out = base + ".zot" | |
| 290 | + | } | |
| 291 | + | if filepath.Ext(out) != ".zot" { | |
| 292 | + | out += ".zot" | |
| 293 | + | } | |
| 294 | + | outAbs, err := filepath.Abs(out) | |
| 295 | + | if err != nil { | |
| 296 | + | return err | |
| 297 | + | } | |
| 298 | + | f, err := os.Create(out) | |
| 299 | + | if err != nil { | |
| 300 | + | return err | |
| 301 | + | } | |
| 302 | + | defer f.Close() | |
| 303 | + | h := sha256.New() | |
| 304 | + | mw := io.MultiWriter(f, h) | |
| 305 | + | enc, err := zstd.NewWriter(mw) | |
| 306 | + | if err != nil { | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | if err := writeCanonicalTar(abs, enc, outAbs); err != nil { | |
| 310 | + | enc.Close() | |
| 311 | + | return err | |
| 312 | + | } | |
| 313 | + | if err := enc.Close(); err != nil { | |
| 314 | + | return err | |
| 315 | + | } | |
| 316 | + | fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil))) | |
| 317 | + | return nil | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func loadZotfile(ref string) (zotfileLoaded, func(), error) { | |
| 321 | + | if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") { | |
| 322 | + | return loadRemoteZotfile(u) | |
| 323 | + | } | |
| 324 | + | info, err := os.Stat(ref) | |
| 325 | + | if err != nil { | |
| 326 | + | return zotfileLoaded{}, nil, err | |
| 327 | + | } | |
| 328 | + | if info.IsDir() { | |
| 329 | + | abs, _ := filepath.Abs(ref) | |
| 330 | + | m, err := readZotManifest(abs) | |
| 331 | + | if err != nil { | |
| 332 | + | return zotfileLoaded{}, nil, err | |
| 333 | + | } | |
| 334 | + | if err := validateZotfileDir(abs); err != nil { | |
| 335 | + | return zotfileLoaded{}, nil, err | |
| 336 | + | } | |
| 337 | + | return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil | |
| 338 | + | } | |
| 339 | + | tmp, err := os.MkdirTemp("", "zotfile-*") | |
| 340 | + | if err != nil { | |
| 341 | + | return zotfileLoaded{}, nil, err | |
| 342 | + | } | |
| 343 | + | cleanup := func() { _ = os.RemoveAll(tmp) } | |
| 344 | + | digest, err := unpackZotArchive(ref, tmp) | |
| 345 | + | if err != nil { | |
| 346 | + | cleanup() | |
| 347 | + | return zotfileLoaded{}, nil, err | |
| 348 | + | } | |
| 349 | + | m, err := readZotManifest(tmp) | |
| 350 | + | if err != nil { | |
| 351 | + | cleanup() | |
| 352 | + | return zotfileLoaded{}, nil, err | |
| 353 | + | } | |
| 354 | + | if err := validateZotfileDir(tmp); err != nil { | |
| 355 | + | cleanup() | |
| 356 | + | return zotfileLoaded{}, nil, err | |
| 357 | + | } | |
| 358 | + | return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | var zotfileHTTPClient = &http.Client{Timeout: 60 * time.Second} | |
| 362 | + | ||
| 363 | + | var githubArchiveURL = func(owner, repo, ref string) string { | |
| 364 | + | return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref)) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func loadRemoteZotfile(u *url.URL) (zotfileLoaded, func(), error) { | |
| 368 | + | if !strings.EqualFold(u.Hostname(), "github.com") { | |
| 369 | + | return zotfileLoaded{}, nil, fmt.Errorf("unsupported zotfile URL host %q; only github.com agent directories are supported", u.Hostname()) | |
| 370 | + | } | |
| 371 | + | owner, repo, ref, subdir, err := parseGitHubAgentURL(u) | |
| 372 | + | if err != nil { | |
| 373 | + | return zotfileLoaded{}, nil, err | |
| 374 | + | } | |
| 375 | + | tmp, err := os.MkdirTemp("", "zotfile-github-*") | |
| 376 | + | if err != nil { | |
| 377 | + | return zotfileLoaded{}, nil, err | |
| 378 | + | } | |
| 379 | + | cleanup := func() { _ = os.RemoveAll(tmp) } | |
| 380 | + | if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil { | |
| 381 | + | cleanup() | |
| 382 | + | return zotfileLoaded{}, nil, err | |
| 383 | + | } | |
| 384 | + | root, err := singleExtractedRoot(tmp) | |
| 385 | + | if err != nil { | |
| 386 | + | cleanup() | |
| 387 | + | return zotfileLoaded{}, nil, err | |
| 388 | + | } | |
| 389 | + | dir := filepath.Join(root, filepath.FromSlash(subdir)) | |
| 390 | + | if subdir == "" { | |
| 391 | + | dir = root | |
| 392 | + | } | |
| 393 | + | if !pathWithin(root, dir) { | |
| 394 | + | cleanup() | |
| 395 | + | return zotfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir) | |
| 396 | + | } | |
| 397 | + | m, err := readZotManifest(dir) | |
| 398 | + | if err != nil { | |
| 399 | + | cleanup() | |
| 400 | + | return zotfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err) | |
| 401 | + | } | |
| 402 | + | if err := validateZotfileDir(dir); err != nil { | |
| 403 | + | cleanup() | |
| 404 | + | return zotfileLoaded{}, nil, err | |
| 405 | + | } | |
| 406 | + | return zotfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) { | |
| 410 | + | parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/") | |
| 411 | + | for i := range parts { | |
| 412 | + | parts[i], err = url.PathUnescape(parts[i]) | |
| 413 | + | if err != nil { | |
| 414 | + | return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err) | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | if len(parts) < 2 || parts[0] == "" || parts[1] == "" { | |
| 418 | + | return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository") | |
| 419 | + | } | |
| 420 | + | owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD" | |
| 421 | + | if len(parts) >= 4 && parts[2] == "tree" { | |
| 422 | + | ref = parts[3] | |
| 423 | + | subdir = strings.Join(parts[4:], "/") | |
| 424 | + | } else { | |
| 425 | + | subdir = strings.Join(parts[2:], "/") | |
| 426 | + | } | |
| 427 | + | if repo == "" || ref == "" { | |
| 428 | + | return "", "", "", "", fmt.Errorf("invalid GitHub agent URL") | |
| 429 | + | } | |
| 430 | + | clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./") | |
| 431 | + | if clean == "." { | |
| 432 | + | clean = "" | |
| 433 | + | } | |
| 434 | + | if clean == ".." || strings.HasPrefix(clean, "../") { | |
| 435 | + | return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir) | |
| 436 | + | } | |
| 437 | + | return owner, repo, ref, clean, nil | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | func downloadGitHubArchive(archiveURL, dst string) error { | |
| 441 | + | req, err := http.NewRequest(http.MethodGet, archiveURL, nil) | |
| 442 | + | if err != nil { | |
| 443 | + | return err | |
| 444 | + | } | |
| 445 | + | req.Header.Set("User-Agent", "zot") | |
| 446 | + | resp, err := zotfileHTTPClient.Do(req) | |
| 447 | + | if err != nil { | |
| 448 | + | return fmt.Errorf("download GitHub repository: %w", err) | |
| 449 | + | } | |
| 450 | + | defer resp.Body.Close() | |
| 451 | + | if resp.StatusCode != http.StatusOK { | |
| 452 | + | return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode) | |
| 453 | + | } | |
| 454 | + | limited := &io.LimitedReader{R: resp.Body, N: maxZotfileCompressedSize + 1} | |
| 455 | + | gr, err := gzip.NewReader(limited) | |
| 456 | + | if err != nil { | |
| 457 | + | return fmt.Errorf("read GitHub archive: %w", err) | |
| 458 | + | } | |
| 459 | + | defer gr.Close() | |
| 460 | + | if err := untar(gr, dst); err != nil { | |
| 461 | + | return fmt.Errorf("extract GitHub archive: %w", err) | |
| 462 | + | } | |
| 463 | + | if limited.N <= 0 { | |
| 464 | + | return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20) | |
| 465 | + | } | |
| 466 | + | return nil | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func pathWithin(root, target string) bool { | |
| 470 | + | rel, err := filepath.Rel(root, target) | |
| 471 | + | return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func singleExtractedRoot(dir string) (string, error) { | |
| 475 | + | entries, err := os.ReadDir(dir) | |
| 476 | + | if err != nil { | |
| 477 | + | return "", err | |
| 478 | + | } | |
| 479 | + | if len(entries) != 1 || !entries[0].IsDir() { | |
| 480 | + | return "", fmt.Errorf("GitHub archive has an unexpected layout") | |
| 481 | + | } | |
| 482 | + | return filepath.Join(dir, entries[0].Name()), nil | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func readZotManifest(dir string) (ZotfileManifest, error) { | |
| 486 | + | var m ZotfileManifest | |
| 487 | + | b, err := os.ReadFile(filepath.Join(dir, "manifest.json")) | |
| 488 | + | if err != nil { | |
| 489 | + | return m, fmt.Errorf("manifest.json is required: %w", err) | |
| 490 | + | } | |
| 491 | + | if err := json.Unmarshal(b, &m); err != nil { | |
| 492 | + | return m, fmt.Errorf("manifest.json: %w", err) | |
| 493 | + | } | |
| 494 | + | if m.Zotfile != 1 { | |
| 495 | + | return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile) | |
| 496 | + | } | |
| 497 | + | name := strings.TrimSpace(m.Name) | |
| 498 | + | if name == "" { | |
| 499 | + | return m, fmt.Errorf("manifest name is required") | |
| 500 | + | } | |
| 501 | + | if name != strings.ToLower(name) || safeAgentName(name) != name { | |
| 502 | + | return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores") | |
| 503 | + | } | |
| 504 | + | if len(m.Permissions.Net.Allow) > 0 { | |
| 505 | + | return m, fmt.Errorf("permissions.net is not supported by the local runtime yet") | |
| 506 | + | } | |
| 507 | + | if len(m.Permissions.Env.Read) > 0 { | |
| 508 | + | return m, fmt.Errorf("permissions.env is not supported by the local runtime yet") | |
| 509 | + | } | |
| 510 | + | mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode)) | |
| 511 | + | if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" { | |
| 512 | + | return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode) | |
| 513 | + | } | |
| 514 | + | if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 { | |
| 515 | + | return m, fmt.Errorf("bash allowlist mode requires at least one command") | |
| 516 | + | } | |
| 517 | + | return m, nil | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | func validateZotfileDir(dir string) error { | |
| 521 | + | st, err := os.Stat(filepath.Join(dir, "AGENT.md")) | |
| 522 | + | if err != nil || !st.Mode().IsRegular() { | |
| 523 | + | return fmt.Errorf("AGENT.md is required") | |
| 524 | + | } | |
| 525 | + | if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 { | |
| 526 | + | return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions") | |
| 527 | + | } | |
| 528 | + | return nil | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | const ( | |
| 532 | + | maxZotfileCompressedSize = 100 << 20 | |
| 533 | + | maxZotfileEntrySize = 64 << 20 | |
| 534 | + | maxZotfileExpandedSize = 256 << 20 | |
| 535 | + | ) | |
| 536 | + | ||
| 537 | + | func unpackZotArchive(path, dst string) (string, error) { | |
| 538 | + | info, err := os.Stat(path) | |
| 539 | + | if err != nil { | |
| 540 | + | return "", err | |
| 541 | + | } | |
| 542 | + | if info.Size() > maxZotfileCompressedSize { | |
| 543 | + | return "", fmt.Errorf("zotfile exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20) | |
| 544 | + | } | |
| 545 | + | b, err := os.ReadFile(path) | |
| 546 | + | if err != nil { | |
| 547 | + | return "", err | |
| 548 | + | } | |
| 549 | + | digest := sha256.Sum256(b) | |
| 550 | + | r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZotfileExpandedSize)) | |
| 551 | + | if err != nil { | |
| 552 | + | // Development fallback for older experiments. | |
| 553 | + | gr, gerr := gzip.NewReader(bytes.NewReader(b)) | |
| 554 | + | if gerr != nil { | |
| 555 | + | return "", err | |
| 556 | + | } | |
| 557 | + | defer gr.Close() | |
| 558 | + | return hex.EncodeToString(digest[:]), untar(gr, dst) | |
| 559 | + | } | |
| 560 | + | defer r.Close() | |
| 561 | + | return hex.EncodeToString(digest[:]), untar(r, dst) | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | func untar(r io.Reader, dst string) error { | |
| 565 | + | tr := tar.NewReader(r) | |
| 566 | + | var expanded int64 | |
| 567 | + | for { | |
| 568 | + | h, err := tr.Next() | |
| 569 | + | if err == io.EOF { | |
| 570 | + | return nil | |
| 571 | + | } | |
| 572 | + | if err != nil { | |
| 573 | + | return err | |
| 574 | + | } | |
| 575 | + | name := filepath.Clean(h.Name) | |
| 576 | + | if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) { | |
| 577 | + | return fmt.Errorf("unsafe path in archive: %s", h.Name) | |
| 578 | + | } | |
| 579 | + | path := filepath.Join(dst, name) | |
| 580 | + | if h.Size < 0 || h.Size > maxZotfileEntrySize || expanded+h.Size > maxZotfileExpandedSize { | |
| 581 | + | return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name) | |
| 582 | + | } | |
| 583 | + | expanded += h.Size | |
| 584 | + | switch h.Typeflag { | |
| 585 | + | case tar.TypeDir: | |
| 586 | + | if err := os.MkdirAll(path, 0o755); err != nil { | |
| 587 | + | return err | |
| 588 | + | } | |
| 589 | + | case tar.TypeReg: | |
| 590 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 591 | + | return err | |
| 592 | + | } | |
| 593 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777) | |
| 594 | + | if err != nil { | |
| 595 | + | return err | |
| 596 | + | } | |
| 597 | + | _, cerr := io.Copy(f, tr) | |
| 598 | + | if err := f.Close(); err != nil && cerr == nil { | |
| 599 | + | cerr = err | |
| 600 | + | } | |
| 601 | + | if cerr != nil { | |
| 602 | + | return cerr | |
| 603 | + | } | |
| 604 | + | } | |
| 605 | + | } | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | func writeCanonicalTar(root string, w io.Writer, exclude ...string) error { | |
| 609 | + | excluded := map[string]bool{} | |
| 610 | + | for _, p := range exclude { | |
| 611 | + | if p == "" { | |
| 612 | + | continue | |
| 613 | + | } | |
| 614 | + | abs, err := filepath.Abs(p) | |
| 615 | + | if err == nil { | |
| 616 | + | excluded[abs] = true | |
| 617 | + | } | |
| 618 | + | } | |
| 619 | + | var files []string | |
| 620 | + | if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |
| 621 | + | if err != nil || path == root { | |
| 622 | + | return err | |
| 623 | + | } | |
| 624 | + | absPath, _ := filepath.Abs(path) | |
| 625 | + | if excluded[absPath] { | |
| 626 | + | return nil | |
| 627 | + | } | |
| 628 | + | if d.Type()&os.ModeSymlink != 0 { | |
| 629 | + | return fmt.Errorf("symlinks are not supported in zotfiles: %s", path) | |
| 630 | + | } | |
| 631 | + | files = append(files, path) | |
| 632 | + | return nil | |
| 633 | + | }); err != nil { | |
| 634 | + | return err | |
| 635 | + | } | |
| 636 | + | sort.Strings(files) | |
| 637 | + | tw := tar.NewWriter(w) | |
| 638 | + | defer tw.Close() | |
| 639 | + | fixed := time.Unix(0, 0) | |
| 640 | + | for _, path := range files { | |
| 641 | + | info, err := os.Stat(path) | |
| 642 | + | if err != nil { | |
| 643 | + | return err | |
| 644 | + | } | |
| 645 | + | rel, _ := filepath.Rel(root, path) | |
| 646 | + | rel = filepath.ToSlash(rel) | |
| 647 | + | h, err := tar.FileInfoHeader(info, "") | |
| 648 | + | if err != nil { | |
| 649 | + | return err | |
| 650 | + | } | |
| 651 | + | h.Name = rel | |
| 652 | + | h.ModTime = fixed | |
| 653 | + | h.AccessTime = fixed | |
| 654 | + | h.ChangeTime = fixed | |
| 655 | + | h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", "" | |
| 656 | + | if info.IsDir() && !strings.HasSuffix(h.Name, "/") { | |
| 657 | + | h.Name += "/" | |
| 658 | + | } | |
| 659 | + | if err := tw.WriteHeader(h); err != nil { | |
| 660 | + | return err | |
| 661 | + | } | |
| 662 | + | if info.Mode().IsRegular() { | |
| 663 | + | f, err := os.Open(path) | |
| 664 | + | if err != nil { | |
| 665 | + | return err | |
| 666 | + | } | |
| 667 | + | _, cerr := io.Copy(tw, f) | |
| 668 | + | _ = f.Close() | |
| 669 | + | if cerr != nil { | |
| 670 | + | return cerr | |
| 671 | + | } | |
| 672 | + | } | |
| 673 | + | } | |
| 674 | + | return nil | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | func checkZotfileRequirements(zf zotfileLoaded, version string) error { | |
| 678 | + | if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" { | |
| 679 | + | if versionOnly(version) == "0.0.0" { | |
| 680 | + | return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min) | |
| 681 | + | } | |
| 682 | + | if versionLess(version, min) { | |
| 683 | + | return fmt.Errorf("agent requires zot %s or newer; running %s", min, versionOnly(version)) | |
| 684 | + | } | |
| 685 | + | } | |
| 686 | + | if len(zf.Manifest.Requirements.OS) > 0 { | |
| 687 | + | ok := false | |
| 688 | + | for _, osName := range zf.Manifest.Requirements.OS { | |
| 689 | + | if osName == runtime.GOOS { | |
| 690 | + | ok = true | |
| 691 | + | break | |
| 692 | + | } | |
| 693 | + | } | |
| 694 | + | if !ok { | |
| 695 | + | return fmt.Errorf("agent does not support %s", runtime.GOOS) | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | if len(zf.Manifest.Requirements.Bin) > 0 { | |
| 699 | + | for _, b := range zf.Manifest.Requirements.Bin { | |
| 700 | + | if _, err := execLookPath(b); err != nil { | |
| 701 | + | return fmt.Errorf("agent requires binary %q", b) | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | } | |
| 705 | + | return nil | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | var execLookPath = exec.LookPath | |
| 709 | + | ||
| 710 | + | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |
| 711 | + | if os.Getenv("ZOT_AGENT_CONSENT") == "1" { | |
| 712 | + | return nil | |
| 713 | + | } | |
| 714 | + | // "ask" deliberately requires approval on every launch. Other consent | |
| 715 | + | // is durable only for this exact artifact digest, so any package change | |
| 716 | + | // causes a fresh prompt. | |
| 717 | + | consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json") | |
| 718 | + | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" { | |
| 719 | + | if _, err := os.Stat(consentPath); err == nil { | |
| 720 | + | return nil | |
| 721 | + | } | |
| 722 | + | } | |
| 723 | + | fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version) | |
| 724 | + | fmt.Print(permissionSummary(perms)) | |
| 725 | + | if !term.IsTerminal(int(os.Stdin.Fd())) { | |
| 726 | + | return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | |
| 727 | + | } | |
| 728 | + | fmt.Print("\nAllow? [y/N] ") | |
| 729 | + | var answer string | |
| 730 | + | _, _ = fmt.Fscanln(os.Stdin, &answer) | |
| 731 | + | answer = strings.ToLower(strings.TrimSpace(answer)) | |
| 732 | + | if answer != "y" && answer != "yes" { | |
| 733 | + | return fmt.Errorf("declined") | |
| 734 | + | } | |
| 735 | + | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | |
| 736 | + | return nil | |
| 737 | + | } | |
| 738 | + | if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil { | |
| 739 | + | return fmt.Errorf("save agent consent: %w", err) | |
| 740 | + | } | |
| 741 | + | receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version} | |
| 742 | + | data, _ := json.MarshalIndent(receipt, "", " ") | |
| 743 | + | if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil { | |
| 744 | + | return fmt.Errorf("save agent consent: %w", err) | |
| 745 | + | } | |
| 746 | + | return nil | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | func permissionSummary(p tools.PermissionSet) string { | |
| 750 | + | var sb strings.Builder | |
| 751 | + | if len(p.FS.Read) > 0 { | |
| 752 | + | fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", ")) | |
| 753 | + | } else { | |
| 754 | + | fmt.Fprintln(&sb, " fs read: none") | |
| 755 | + | } | |
| 756 | + | if len(p.FS.Write) > 0 { | |
| 757 | + | fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", ")) | |
| 758 | + | } else { | |
| 759 | + | fmt.Fprintln(&sb, " fs write: none") | |
| 760 | + | } | |
| 761 | + | mode := p.Bash.Mode | |
| 762 | + | if mode == "" { | |
| 763 | + | mode = "none" | |
| 764 | + | } | |
| 765 | + | fmt.Fprintf(&sb, " bash: %s", mode) | |
| 766 | + | if len(p.Bash.Allow) > 0 { | |
| 767 | + | fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", ")) | |
| 768 | + | } | |
| 769 | + | fmt.Fprintln(&sb) | |
| 770 | + | if len(p.Net.Allow) > 0 { | |
| 771 | + | fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", ")) | |
| 772 | + | } | |
| 773 | + | if len(p.Env.Read) > 0 { | |
| 774 | + | fmt.Fprintf(&sb, " env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", ")) | |
| 775 | + | } | |
| 776 | + | return sb.String() | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | func bundledExtensionDirs(root string) []string { | |
| 780 | + | entries, err := os.ReadDir(root) | |
| 781 | + | if err != nil { | |
| 782 | + | return nil | |
| 783 | + | } | |
| 784 | + | var out []string | |
| 785 | + | for _, e := range entries { | |
| 786 | + | if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) { | |
| 787 | + | out = append(out, filepath.Join(root, e.Name())) | |
| 788 | + | } | |
| 789 | + | } | |
| 790 | + | return out | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } | |
| 794 | + | func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() } | |
| 795 | + | ||
| 796 | + | func safeAgentName(s string) string { | |
| 797 | + | s = strings.TrimSpace(strings.ToLower(s)) | |
| 798 | + | s = strings.ReplaceAll(s, "/", "_") | |
| 799 | + | s = strings.ReplaceAll(s, "\\", "_") | |
| 800 | + | s = strings.Map(func(r rune) rune { | |
| 801 | + | if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { | |
| 802 | + | return r | |
| 803 | + | } | |
| 804 | + | return '-' | |
| 805 | + | }, s) | |
| 806 | + | return strings.Trim(s, "-._") | |
| 807 | + | } | |
| 808 | + | ||
| 809 | + | func digestDirectory(dir string) string { | |
| 810 | + | h := sha256.New() | |
| 811 | + | _ = writeCanonicalTar(dir, h) | |
| 812 | + | return hex.EncodeToString(h.Sum(nil)) | |
| 813 | + | } | |
| @@ -0,0 +1,813 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "archive/tar" | |||
| 5 | + | "bytes" | |||
| 6 | + | "compress/gzip" | |||
| 7 | + | "crypto/sha256" | |||
| 8 | + | "encoding/hex" | |||
| 9 | + | "encoding/json" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "net/http" | |||
| 13 | + | "net/url" | |||
| 14 | + | "os" | |||
| 15 | + | "os/exec" | |||
| 16 | + | "path/filepath" | |||
| 17 | + | "runtime" | |||
| 18 | + | "sort" | |||
| 19 | + | "strings" | |||
| 20 | + | "time" | |||
| 21 | + | ||||
| 22 | + | "github.com/klauspost/compress/zstd" | |||
| 23 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 24 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 25 | + | "github.com/patriceckhart/zot/packages/provider/auth" | |||
| 26 | + | "golang.org/x/term" | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | type ZotfileManifest struct { | |||
| 30 | + | Zotfile int `json:"zotfile"` | |||
| 31 | + | Name string `json:"name"` | |||
| 32 | + | Version string `json:"version"` | |||
| 33 | + | Description string `json:"description"` | |||
| 34 | + | License string `json:"license"` | |||
| 35 | + | Runtime struct { | |||
| 36 | + | MinZot string `json:"min_zot"` | |||
| 37 | + | } `json:"runtime"` | |||
| 38 | + | Model struct { | |||
| 39 | + | Requires []string `json:"requires"` | |||
| 40 | + | MinContext int `json:"min_context"` | |||
| 41 | + | Preferred []string `json:"preferred"` | |||
| 42 | + | MinTier string `json:"min_tier"` | |||
| 43 | + | } `json:"model"` | |||
| 44 | + | Permissions tools.PermissionSet `json:"permissions"` | |||
| 45 | + | Requirements struct { | |||
| 46 | + | Bin []string `json:"bin"` | |||
| 47 | + | OS []string `json:"os"` | |||
| 48 | + | } `json:"requirements"` | |||
| 49 | + | Entry struct { | |||
| 50 | + | Greeting string `json:"greeting"` | |||
| 51 | + | DefaultPrompt *string `json:"default_prompt"` | |||
| 52 | + | } `json:"entry"` | |||
| 53 | + | ReplaceSystemPrompt bool `json:"replace_system_prompt"` | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | type zotfileLoaded struct { | |||
| 57 | + | Dir string | |||
| 58 | + | Temp bool | |||
| 59 | + | Digest string | |||
| 60 | + | Manifest ZotfileManifest | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func runZotfileCommand(rawArgs []string, version string) (bool, error) { | |||
| 64 | + | if len(rawArgs) == 0 { | |||
| 65 | + | return false, nil | |||
| 66 | + | } | |||
| 67 | + | switch rawArgs[0] { | |||
| 68 | + | case "pack": | |||
| 69 | + | dir := "." | |||
| 70 | + | out := "" | |||
| 71 | + | if len(rawArgs) > 1 { | |||
| 72 | + | dir = rawArgs[1] | |||
| 73 | + | } | |||
| 74 | + | if len(rawArgs) > 2 { | |||
| 75 | + | out = rawArgs[2] | |||
| 76 | + | } | |||
| 77 | + | return true, zotPack(dir, out) | |||
| 78 | + | case "inspect": | |||
| 79 | + | if len(rawArgs) < 2 { | |||
| 80 | + | return true, fmt.Errorf("zot inspect requires a .zot file, directory, or GitHub URL") | |||
| 81 | + | } | |||
| 82 | + | return true, zotInspect(rawArgs[1]) | |||
| 83 | + | case "verify": | |||
| 84 | + | if len(rawArgs) < 2 { | |||
| 85 | + | return true, fmt.Errorf("zot verify requires a .zot file, directory, or GitHub URL") | |||
| 86 | + | } | |||
| 87 | + | zf, cleanup, err := loadZotfile(rawArgs[1]) | |||
| 88 | + | if cleanup != nil { | |||
| 89 | + | defer cleanup() | |||
| 90 | + | } | |||
| 91 | + | if err != nil { | |||
| 92 | + | return true, err | |||
| 93 | + | } | |||
| 94 | + | fmt.Printf("ok digest sha256:%s\n", zf.Digest) | |||
| 95 | + | return true, nil | |||
| 96 | + | case "run": | |||
| 97 | + | if len(rawArgs) < 2 { | |||
| 98 | + | return true, fmt.Errorf("zot run requires a .zot file, directory, or GitHub URL") | |||
| 99 | + | } | |||
| 100 | + | ref := rawArgs[1] | |||
| 101 | + | rest := rawArgs[2:] | |||
| 102 | + | args, err := ParseArgs(rest) | |||
| 103 | + | if err != nil { | |||
| 104 | + | PrintHelp(version) | |||
| 105 | + | return true, err | |||
| 106 | + | } | |||
| 107 | + | return true, runLocalZotfile(ref, args, version) | |||
| 108 | + | default: | |||
| 109 | + | return false, nil | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func runLocalZotfile(ref string, args Args, version string) error { | |||
| 114 | + | prepareRuntimeCatalog() | |||
| 115 | + | zf, cleanup, err := loadZotfile(ref) | |||
| 116 | + | if cleanup != nil { | |||
| 117 | + | defer cleanup() | |||
| 118 | + | } | |||
| 119 | + | if err != nil { | |||
| 120 | + | return err | |||
| 121 | + | } | |||
| 122 | + | if err := checkZotfileRequirements(zf, version); err != nil { | |||
| 123 | + | return err | |||
| 124 | + | } | |||
| 125 | + | agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data") | |||
| 126 | + | if err := os.MkdirAll(agentData, 0o755); err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | perms := zf.Manifest.Permissions.Expand(args.CWD, agentData) | |||
| 130 | + | if err := consentZotfile(zf, perms); err != nil { | |||
| 131 | + | return err | |||
| 132 | + | } | |||
| 133 | + | agentPath := filepath.Join(zf.Dir, "AGENT.md") | |||
| 134 | + | agentPrompt, err := os.ReadFile(agentPath) | |||
| 135 | + | if err != nil { | |||
| 136 | + | return fmt.Errorf("read AGENT.md: %w", err) | |||
| 137 | + | } | |||
| 138 | + | if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 141 | + | if zf.Manifest.ReplaceSystemPrompt { | |||
| 142 | + | args.SystemPrompt = strings.TrimSpace(string(agentPrompt)) | |||
| 143 | + | } else { | |||
| 144 | + | args.AppendSystemPrompt = append(args.AppendSystemPrompt, strings.TrimSpace(string(agentPrompt))) | |||
| 145 | + | } | |||
| 146 | + | if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil { | |||
| 147 | + | args.Prompt = *zf.Manifest.Entry.DefaultPrompt | |||
| 148 | + | } | |||
| 149 | + | if dirExists(filepath.Join(zf.Dir, "skills")) { | |||
| 150 | + | old := os.Getenv("ZOT_AGENT_SKILLS") | |||
| 151 | + | v := filepath.Join(zf.Dir, "skills") | |||
| 152 | + | if old != "" { | |||
| 153 | + | v += string(os.PathListSeparator) + old | |||
| 154 | + | } | |||
| 155 | + | _ = os.Setenv("ZOT_AGENT_SKILLS", v) | |||
| 156 | + | defer os.Setenv("ZOT_AGENT_SKILLS", old) | |||
| 157 | + | } | |||
| 158 | + | args.AgentName = zf.Manifest.Name | |||
| 159 | + | args.AgentDataDir = agentData | |||
| 160 | + | args.PermissionSet = &perms | |||
| 161 | + | return runWithArgs(args, version) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func zotInspect(ref string) error { | |||
| 165 | + | zf, cleanup, err := loadZotfile(ref) | |||
| 166 | + | if cleanup != nil { | |||
| 167 | + | defer cleanup() | |||
| 168 | + | } | |||
| 169 | + | if err != nil { | |||
| 170 | + | return err | |||
| 171 | + | } | |||
| 172 | + | m := zf.Manifest | |||
| 173 | + | fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest) | |||
| 174 | + | fmt.Println("\npermissions:") | |||
| 175 | + | fmt.Print(permissionSummary(m.Permissions)) | |||
| 176 | + | fmt.Println("\nfiles:") | |||
| 177 | + | return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error { | |||
| 178 | + | if err != nil || path == zf.Dir { | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | rel, _ := filepath.Rel(zf.Dir, path) | |||
| 182 | + | if d.IsDir() { | |||
| 183 | + | rel += "/" | |||
| 184 | + | } | |||
| 185 | + | fmt.Println(" " + filepath.ToSlash(rel)) | |||
| 186 | + | return nil | |||
| 187 | + | }) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error { | |||
| 191 | + | minCtx := m.Model.MinContext | |||
| 192 | + | requires := map[string]bool{} | |||
| 193 | + | for _, capability := range m.Model.Requires { | |||
| 194 | + | requires[strings.ToLower(strings.TrimSpace(capability))] = true | |||
| 195 | + | } | |||
| 196 | + | for capability := range requires { | |||
| 197 | + | if capability != "tools" && capability != "vision" && capability != "reasoning" { | |||
| 198 | + | return fmt.Errorf("unsupported model requirement %q", capability) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | if m.Model.MinTier != "" { | |||
| 202 | + | return fmt.Errorf("model.min_tier is not supported by this zot version") | |||
| 203 | + | } | |||
| 204 | + | compatible := func(model provider.Model) bool { | |||
| 205 | + | if model.ContextWindow < minCtx { | |||
| 206 | + | return false | |||
| 207 | + | } | |||
| 208 | + | if requires["reasoning"] && !model.Reasoning { | |||
| 209 | + | return false | |||
| 210 | + | } | |||
| 211 | + | // Every model exposed by the current catalog supports text and tools. | |||
| 212 | + | // Vision support is not represented in Model yet, so fail closed. | |||
| 213 | + | return !requires["vision"] | |||
| 214 | + | } | |||
| 215 | + | if minCtx <= 0 && len(requires) == 0 { | |||
| 216 | + | return nil | |||
| 217 | + | } | |||
| 218 | + | if args.Model != "" { | |||
| 219 | + | model, err := provider.FindModel(args.Provider, args.Model) | |||
| 220 | + | if err != nil { | |||
| 221 | + | model, err = provider.FindModel("", args.Model) | |||
| 222 | + | } | |||
| 223 | + | if err != nil { | |||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | if !compatible(model) { | |||
| 227 | + | return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID) | |||
| 228 | + | } | |||
| 229 | + | return nil | |||
| 230 | + | } | |||
| 231 | + | cfg, _ := LoadConfig() | |||
| 232 | + | if cfg.Model != "" { | |||
| 233 | + | model, err := provider.FindModel(cfg.Provider, cfg.Model) | |||
| 234 | + | if err == nil && compatible(model) { | |||
| 235 | + | return nil | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | for _, id := range m.Model.Preferred { | |||
| 239 | + | model, err := provider.FindModel("", id) | |||
| 240 | + | if err == nil && compatible(model) { | |||
| 241 | + | args.Provider = model.Provider | |||
| 242 | + | args.Model = model.ID | |||
| 243 | + | return nil | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | for _, model := range provider.Active() { | |||
| 247 | + | if compatible(model) { | |||
| 248 | + | args.Provider = model.Provider | |||
| 249 | + | args.Model = model.ID | |||
| 250 | + | return nil | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | return fmt.Errorf("no catalog model satisfies the agent requirements") | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func prepareRuntimeCatalog() { | |||
| 257 | + | LoadCachedModels() | |||
| 258 | + | LoadUserModels() | |||
| 259 | + | if cps := provider.CustomProviders(); len(cps) > 0 { | |||
| 260 | + | var names []string | |||
| 261 | + | for name := range cps { | |||
| 262 | + | if !isBuiltinProvider(name) { | |||
| 263 | + | names = append(names, name) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | auth.SetExtraAPIKeyProviders(names) | |||
| 267 | + | } | |||
| 268 | + | ValidateAndRepairConfig() | |||
| 269 | + | RefreshModelsAsync() | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func zotPack(dir, out string) error { | |||
| 273 | + | abs, err := filepath.Abs(dir) | |||
| 274 | + | if err != nil { | |||
| 275 | + | return err | |||
| 276 | + | } | |||
| 277 | + | if _, err := readZotManifest(abs); err != nil { | |||
| 278 | + | return err | |||
| 279 | + | } | |||
| 280 | + | if err := validateZotfileDir(abs); err != nil { | |||
| 281 | + | return err | |||
| 282 | + | } | |||
| 283 | + | if out == "" { | |||
| 284 | + | m, _ := readZotManifest(abs) | |||
| 285 | + | base := safeAgentName(m.Name) | |||
| 286 | + | if base == "" { | |||
| 287 | + | base = filepath.Base(abs) | |||
| 288 | + | } | |||
| 289 | + | out = base + ".zot" | |||
| 290 | + | } | |||
| 291 | + | if filepath.Ext(out) != ".zot" { | |||
| 292 | + | out += ".zot" | |||
| 293 | + | } | |||
| 294 | + | outAbs, err := filepath.Abs(out) | |||
| 295 | + | if err != nil { | |||
| 296 | + | return err | |||
| 297 | + | } | |||
| 298 | + | f, err := os.Create(out) | |||
| 299 | + | if err != nil { | |||
| 300 | + | return err | |||
| 301 | + | } | |||
| 302 | + | defer f.Close() | |||
| 303 | + | h := sha256.New() | |||
| 304 | + | mw := io.MultiWriter(f, h) | |||
| 305 | + | enc, err := zstd.NewWriter(mw) | |||
| 306 | + | if err != nil { | |||
| 307 | + | return err | |||
| 308 | + | } | |||
| 309 | + | if err := writeCanonicalTar(abs, enc, outAbs); err != nil { | |||
| 310 | + | enc.Close() | |||
| 311 | + | return err | |||
| 312 | + | } | |||
| 313 | + | if err := enc.Close(); err != nil { | |||
| 314 | + | return err | |||
| 315 | + | } | |||
| 316 | + | fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil))) | |||
| 317 | + | return nil | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func loadZotfile(ref string) (zotfileLoaded, func(), error) { | |||
| 321 | + | if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") { | |||
| 322 | + | return loadRemoteZotfile(u) | |||
| 323 | + | } | |||
| 324 | + | info, err := os.Stat(ref) | |||
| 325 | + | if err != nil { | |||
| 326 | + | return zotfileLoaded{}, nil, err | |||
| 327 | + | } | |||
| 328 | + | if info.IsDir() { | |||
| 329 | + | abs, _ := filepath.Abs(ref) | |||
| 330 | + | m, err := readZotManifest(abs) | |||
| 331 | + | if err != nil { | |||
| 332 | + | return zotfileLoaded{}, nil, err | |||
| 333 | + | } | |||
| 334 | + | if err := validateZotfileDir(abs); err != nil { | |||
| 335 | + | return zotfileLoaded{}, nil, err | |||
| 336 | + | } | |||
| 337 | + | return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil | |||
| 338 | + | } | |||
| 339 | + | tmp, err := os.MkdirTemp("", "zotfile-*") | |||
| 340 | + | if err != nil { | |||
| 341 | + | return zotfileLoaded{}, nil, err | |||
| 342 | + | } | |||
| 343 | + | cleanup := func() { _ = os.RemoveAll(tmp) } | |||
| 344 | + | digest, err := unpackZotArchive(ref, tmp) | |||
| 345 | + | if err != nil { | |||
| 346 | + | cleanup() | |||
| 347 | + | return zotfileLoaded{}, nil, err | |||
| 348 | + | } | |||
| 349 | + | m, err := readZotManifest(tmp) | |||
| 350 | + | if err != nil { | |||
| 351 | + | cleanup() | |||
| 352 | + | return zotfileLoaded{}, nil, err | |||
| 353 | + | } | |||
| 354 | + | if err := validateZotfileDir(tmp); err != nil { | |||
| 355 | + | cleanup() | |||
| 356 | + | return zotfileLoaded{}, nil, err | |||
| 357 | + | } | |||
| 358 | + | return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | var zotfileHTTPClient = &http.Client{Timeout: 60 * time.Second} | |||
| 362 | + | ||||
| 363 | + | var githubArchiveURL = func(owner, repo, ref string) string { | |||
| 364 | + | return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref)) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func loadRemoteZotfile(u *url.URL) (zotfileLoaded, func(), error) { | |||
| 368 | + | if !strings.EqualFold(u.Hostname(), "github.com") { | |||
| 369 | + | return zotfileLoaded{}, nil, fmt.Errorf("unsupported zotfile URL host %q; only github.com agent directories are supported", u.Hostname()) | |||
| 370 | + | } | |||
| 371 | + | owner, repo, ref, subdir, err := parseGitHubAgentURL(u) | |||
| 372 | + | if err != nil { | |||
| 373 | + | return zotfileLoaded{}, nil, err | |||
| 374 | + | } | |||
| 375 | + | tmp, err := os.MkdirTemp("", "zotfile-github-*") | |||
| 376 | + | if err != nil { | |||
| 377 | + | return zotfileLoaded{}, nil, err | |||
| 378 | + | } | |||
| 379 | + | cleanup := func() { _ = os.RemoveAll(tmp) } | |||
| 380 | + | if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil { | |||
| 381 | + | cleanup() | |||
| 382 | + | return zotfileLoaded{}, nil, err | |||
| 383 | + | } | |||
| 384 | + | root, err := singleExtractedRoot(tmp) | |||
| 385 | + | if err != nil { | |||
| 386 | + | cleanup() | |||
| 387 | + | return zotfileLoaded{}, nil, err | |||
| 388 | + | } | |||
| 389 | + | dir := filepath.Join(root, filepath.FromSlash(subdir)) | |||
| 390 | + | if subdir == "" { | |||
| 391 | + | dir = root | |||
| 392 | + | } | |||
| 393 | + | if !pathWithin(root, dir) { | |||
| 394 | + | cleanup() | |||
| 395 | + | return zotfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir) | |||
| 396 | + | } | |||
| 397 | + | m, err := readZotManifest(dir) | |||
| 398 | + | if err != nil { | |||
| 399 | + | cleanup() | |||
| 400 | + | return zotfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err) | |||
| 401 | + | } | |||
| 402 | + | if err := validateZotfileDir(dir); err != nil { | |||
| 403 | + | cleanup() | |||
| 404 | + | return zotfileLoaded{}, nil, err | |||
| 405 | + | } | |||
| 406 | + | return zotfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) { | |||
| 410 | + | parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/") | |||
| 411 | + | for i := range parts { | |||
| 412 | + | parts[i], err = url.PathUnescape(parts[i]) | |||
| 413 | + | if err != nil { | |||
| 414 | + | return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err) | |||
| 415 | + | } | |||
| 416 | + | } | |||
| 417 | + | if len(parts) < 2 || parts[0] == "" || parts[1] == "" { | |||
| 418 | + | return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository") | |||
| 419 | + | } | |||
| 420 | + | owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD" | |||
| 421 | + | if len(parts) >= 4 && parts[2] == "tree" { | |||
| 422 | + | ref = parts[3] | |||
| 423 | + | subdir = strings.Join(parts[4:], "/") | |||
| 424 | + | } else { | |||
| 425 | + | subdir = strings.Join(parts[2:], "/") | |||
| 426 | + | } | |||
| 427 | + | if repo == "" || ref == "" { | |||
| 428 | + | return "", "", "", "", fmt.Errorf("invalid GitHub agent URL") | |||
| 429 | + | } | |||
| 430 | + | clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./") | |||
| 431 | + | if clean == "." { | |||
| 432 | + | clean = "" | |||
| 433 | + | } | |||
| 434 | + | if clean == ".." || strings.HasPrefix(clean, "../") { | |||
| 435 | + | return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir) | |||
| 436 | + | } | |||
| 437 | + | return owner, repo, ref, clean, nil | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | func downloadGitHubArchive(archiveURL, dst string) error { | |||
| 441 | + | req, err := http.NewRequest(http.MethodGet, archiveURL, nil) | |||
| 442 | + | if err != nil { | |||
| 443 | + | return err | |||
| 444 | + | } | |||
| 445 | + | req.Header.Set("User-Agent", "zot") | |||
| 446 | + | resp, err := zotfileHTTPClient.Do(req) | |||
| 447 | + | if err != nil { | |||
| 448 | + | return fmt.Errorf("download GitHub repository: %w", err) | |||
| 449 | + | } | |||
| 450 | + | defer resp.Body.Close() | |||
| 451 | + | if resp.StatusCode != http.StatusOK { | |||
| 452 | + | return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode) | |||
| 453 | + | } | |||
| 454 | + | limited := &io.LimitedReader{R: resp.Body, N: maxZotfileCompressedSize + 1} | |||
| 455 | + | gr, err := gzip.NewReader(limited) | |||
| 456 | + | if err != nil { | |||
| 457 | + | return fmt.Errorf("read GitHub archive: %w", err) | |||
| 458 | + | } | |||
| 459 | + | defer gr.Close() | |||
| 460 | + | if err := untar(gr, dst); err != nil { | |||
| 461 | + | return fmt.Errorf("extract GitHub archive: %w", err) | |||
| 462 | + | } | |||
| 463 | + | if limited.N <= 0 { | |||
| 464 | + | return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20) | |||
| 465 | + | } | |||
| 466 | + | return nil | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | func pathWithin(root, target string) bool { | |||
| 470 | + | rel, err := filepath.Rel(root, target) | |||
| 471 | + | return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | func singleExtractedRoot(dir string) (string, error) { | |||
| 475 | + | entries, err := os.ReadDir(dir) | |||
| 476 | + | if err != nil { | |||
| 477 | + | return "", err | |||
| 478 | + | } | |||
| 479 | + | if len(entries) != 1 || !entries[0].IsDir() { | |||
| 480 | + | return "", fmt.Errorf("GitHub archive has an unexpected layout") | |||
| 481 | + | } | |||
| 482 | + | return filepath.Join(dir, entries[0].Name()), nil | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | func readZotManifest(dir string) (ZotfileManifest, error) { | |||
| 486 | + | var m ZotfileManifest | |||
| 487 | + | b, err := os.ReadFile(filepath.Join(dir, "manifest.json")) | |||
| 488 | + | if err != nil { | |||
| 489 | + | return m, fmt.Errorf("manifest.json is required: %w", err) | |||
| 490 | + | } | |||
| 491 | + | if err := json.Unmarshal(b, &m); err != nil { | |||
| 492 | + | return m, fmt.Errorf("manifest.json: %w", err) | |||
| 493 | + | } | |||
| 494 | + | if m.Zotfile != 1 { | |||
| 495 | + | return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile) | |||
| 496 | + | } | |||
| 497 | + | name := strings.TrimSpace(m.Name) | |||
| 498 | + | if name == "" { | |||
| 499 | + | return m, fmt.Errorf("manifest name is required") | |||
| 500 | + | } | |||
| 501 | + | if name != strings.ToLower(name) || safeAgentName(name) != name { | |||
| 502 | + | return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores") | |||
| 503 | + | } | |||
| 504 | + | if len(m.Permissions.Net.Allow) > 0 { | |||
| 505 | + | return m, fmt.Errorf("permissions.net is not supported by the local runtime yet") | |||
| 506 | + | } | |||
| 507 | + | if len(m.Permissions.Env.Read) > 0 { | |||
| 508 | + | return m, fmt.Errorf("permissions.env is not supported by the local runtime yet") | |||
| 509 | + | } | |||
| 510 | + | mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode)) | |||
| 511 | + | if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" { | |||
| 512 | + | return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode) | |||
| 513 | + | } | |||
| 514 | + | if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 { | |||
| 515 | + | return m, fmt.Errorf("bash allowlist mode requires at least one command") | |||
| 516 | + | } | |||
| 517 | + | return m, nil | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | func validateZotfileDir(dir string) error { | |||
| 521 | + | st, err := os.Stat(filepath.Join(dir, "AGENT.md")) | |||
| 522 | + | if err != nil || !st.Mode().IsRegular() { | |||
| 523 | + | return fmt.Errorf("AGENT.md is required") | |||
| 524 | + | } | |||
| 525 | + | if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 { | |||
| 526 | + | return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions") | |||
| 527 | + | } | |||
| 528 | + | return nil | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | const ( | |||
| 532 | + | maxZotfileCompressedSize = 100 << 20 | |||
| 533 | + | maxZotfileEntrySize = 64 << 20 | |||
| 534 | + | maxZotfileExpandedSize = 256 << 20 | |||
| 535 | + | ) | |||
| 536 | + | ||||
| 537 | + | func unpackZotArchive(path, dst string) (string, error) { | |||
| 538 | + | info, err := os.Stat(path) | |||
| 539 | + | if err != nil { | |||
| 540 | + | return "", err | |||
| 541 | + | } | |||
| 542 | + | if info.Size() > maxZotfileCompressedSize { | |||
| 543 | + | return "", fmt.Errorf("zotfile exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20) | |||
| 544 | + | } | |||
| 545 | + | b, err := os.ReadFile(path) | |||
| 546 | + | if err != nil { | |||
| 547 | + | return "", err | |||
| 548 | + | } | |||
| 549 | + | digest := sha256.Sum256(b) | |||
| 550 | + | r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZotfileExpandedSize)) | |||
| 551 | + | if err != nil { | |||
| 552 | + | // Development fallback for older experiments. | |||
| 553 | + | gr, gerr := gzip.NewReader(bytes.NewReader(b)) | |||
| 554 | + | if gerr != nil { | |||
| 555 | + | return "", err | |||
| 556 | + | } | |||
| 557 | + | defer gr.Close() | |||
| 558 | + | return hex.EncodeToString(digest[:]), untar(gr, dst) | |||
| 559 | + | } | |||
| 560 | + | defer r.Close() | |||
| 561 | + | return hex.EncodeToString(digest[:]), untar(r, dst) | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | func untar(r io.Reader, dst string) error { | |||
| 565 | + | tr := tar.NewReader(r) | |||
| 566 | + | var expanded int64 | |||
| 567 | + | for { | |||
| 568 | + | h, err := tr.Next() | |||
| 569 | + | if err == io.EOF { | |||
| 570 | + | return nil | |||
| 571 | + | } | |||
| 572 | + | if err != nil { | |||
| 573 | + | return err | |||
| 574 | + | } | |||
| 575 | + | name := filepath.Clean(h.Name) | |||
| 576 | + | if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) { | |||
| 577 | + | return fmt.Errorf("unsafe path in archive: %s", h.Name) | |||
| 578 | + | } | |||
| 579 | + | path := filepath.Join(dst, name) | |||
| 580 | + | if h.Size < 0 || h.Size > maxZotfileEntrySize || expanded+h.Size > maxZotfileExpandedSize { | |||
| 581 | + | return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name) | |||
| 582 | + | } | |||
| 583 | + | expanded += h.Size | |||
| 584 | + | switch h.Typeflag { | |||
| 585 | + | case tar.TypeDir: | |||
| 586 | + | if err := os.MkdirAll(path, 0o755); err != nil { | |||
| 587 | + | return err | |||
| 588 | + | } | |||
| 589 | + | case tar.TypeReg: | |||
| 590 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 591 | + | return err | |||
| 592 | + | } | |||
| 593 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777) | |||
| 594 | + | if err != nil { | |||
| 595 | + | return err | |||
| 596 | + | } | |||
| 597 | + | _, cerr := io.Copy(f, tr) | |||
| 598 | + | if err := f.Close(); err != nil && cerr == nil { | |||
| 599 | + | cerr = err | |||
| 600 | + | } | |||
| 601 | + | if cerr != nil { | |||
| 602 | + | return cerr | |||
| 603 | + | } | |||
| 604 | + | } | |||
| 605 | + | } | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | func writeCanonicalTar(root string, w io.Writer, exclude ...string) error { | |||
| 609 | + | excluded := map[string]bool{} | |||
| 610 | + | for _, p := range exclude { | |||
| 611 | + | if p == "" { | |||
| 612 | + | continue | |||
| 613 | + | } | |||
| 614 | + | abs, err := filepath.Abs(p) | |||
| 615 | + | if err == nil { | |||
| 616 | + | excluded[abs] = true | |||
| 617 | + | } | |||
| 618 | + | } | |||
| 619 | + | var files []string | |||
| 620 | + | if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |||
| 621 | + | if err != nil || path == root { | |||
| 622 | + | return err | |||
| 623 | + | } | |||
| 624 | + | absPath, _ := filepath.Abs(path) | |||
| 625 | + | if excluded[absPath] { | |||
| 626 | + | return nil | |||
| 627 | + | } | |||
| 628 | + | if d.Type()&os.ModeSymlink != 0 { | |||
| 629 | + | return fmt.Errorf("symlinks are not supported in zotfiles: %s", path) | |||
| 630 | + | } | |||
| 631 | + | files = append(files, path) | |||
| 632 | + | return nil | |||
| 633 | + | }); err != nil { | |||
| 634 | + | return err | |||
| 635 | + | } | |||
| 636 | + | sort.Strings(files) | |||
| 637 | + | tw := tar.NewWriter(w) | |||
| 638 | + | defer tw.Close() | |||
| 639 | + | fixed := time.Unix(0, 0) | |||
| 640 | + | for _, path := range files { | |||
| 641 | + | info, err := os.Stat(path) | |||
| 642 | + | if err != nil { | |||
| 643 | + | return err | |||
| 644 | + | } | |||
| 645 | + | rel, _ := filepath.Rel(root, path) | |||
| 646 | + | rel = filepath.ToSlash(rel) | |||
| 647 | + | h, err := tar.FileInfoHeader(info, "") | |||
| 648 | + | if err != nil { | |||
| 649 | + | return err | |||
| 650 | + | } | |||
| 651 | + | h.Name = rel | |||
| 652 | + | h.ModTime = fixed | |||
| 653 | + | h.AccessTime = fixed | |||
| 654 | + | h.ChangeTime = fixed | |||
| 655 | + | h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", "" | |||
| 656 | + | if info.IsDir() && !strings.HasSuffix(h.Name, "/") { | |||
| 657 | + | h.Name += "/" | |||
| 658 | + | } | |||
| 659 | + | if err := tw.WriteHeader(h); err != nil { | |||
| 660 | + | return err | |||
| 661 | + | } | |||
| 662 | + | if info.Mode().IsRegular() { | |||
| 663 | + | f, err := os.Open(path) | |||
| 664 | + | if err != nil { | |||
| 665 | + | return err | |||
| 666 | + | } | |||
| 667 | + | _, cerr := io.Copy(tw, f) | |||
| 668 | + | _ = f.Close() | |||
| 669 | + | if cerr != nil { | |||
| 670 | + | return cerr | |||
| 671 | + | } | |||
| 672 | + | } | |||
| 673 | + | } | |||
| 674 | + | return nil | |||
| 675 | + | } | |||
| 676 | + | ||||
| 677 | + | func checkZotfileRequirements(zf zotfileLoaded, version string) error { | |||
| 678 | + | if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" { | |||
| 679 | + | if versionOnly(version) == "0.0.0" { | |||
| 680 | + | return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min) | |||
| 681 | + | } | |||
| 682 | + | if versionLess(version, min) { | |||
| 683 | + | return fmt.Errorf("agent requires zot %s or newer; running %s", min, versionOnly(version)) | |||
| 684 | + | } | |||
| 685 | + | } | |||
| 686 | + | if len(zf.Manifest.Requirements.OS) > 0 { | |||
| 687 | + | ok := false | |||
| 688 | + | for _, osName := range zf.Manifest.Requirements.OS { | |||
| 689 | + | if osName == runtime.GOOS { | |||
| 690 | + | ok = true | |||
| 691 | + | break | |||
| 692 | + | } | |||
| 693 | + | } | |||
| 694 | + | if !ok { | |||
| 695 | + | return fmt.Errorf("agent does not support %s", runtime.GOOS) | |||
| 696 | + | } | |||
| 697 | + | } | |||
| 698 | + | if len(zf.Manifest.Requirements.Bin) > 0 { | |||
| 699 | + | for _, b := range zf.Manifest.Requirements.Bin { | |||
| 700 | + | if _, err := execLookPath(b); err != nil { | |||
| 701 | + | return fmt.Errorf("agent requires binary %q", b) | |||
| 702 | + | } | |||
| 703 | + | } | |||
| 704 | + | } | |||
| 705 | + | return nil | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | var execLookPath = exec.LookPath | |||
| 709 | + | ||||
| 710 | + | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||
| 711 | + | if os.Getenv("ZOT_AGENT_CONSENT") == "1" { | |||
| 712 | + | return nil | |||
| 713 | + | } | |||
| 714 | + | // "ask" deliberately requires approval on every launch. Other consent | |||
| 715 | + | // is durable only for this exact artifact digest, so any package change | |||
| 716 | + | // causes a fresh prompt. | |||
| 717 | + | consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json") | |||
| 718 | + | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" { | |||
| 719 | + | if _, err := os.Stat(consentPath); err == nil { | |||
| 720 | + | return nil | |||
| 721 | + | } | |||
| 722 | + | } | |||
| 723 | + | fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version) | |||
| 724 | + | fmt.Print(permissionSummary(perms)) | |||
| 725 | + | if !term.IsTerminal(int(os.Stdin.Fd())) { | |||
| 726 | + | return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | |||
| 727 | + | } | |||
| 728 | + | fmt.Print("\nAllow? [y/N] ") | |||
| 729 | + | var answer string | |||
| 730 | + | _, _ = fmt.Fscanln(os.Stdin, &answer) | |||
| 731 | + | answer = strings.ToLower(strings.TrimSpace(answer)) | |||
| 732 | + | if answer != "y" && answer != "yes" { | |||
| 733 | + | return fmt.Errorf("declined") | |||
| 734 | + | } | |||
| 735 | + | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | |||
| 736 | + | return nil | |||
| 737 | + | } | |||
| 738 | + | if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil { | |||
| 739 | + | return fmt.Errorf("save agent consent: %w", err) | |||
| 740 | + | } | |||
| 741 | + | receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version} | |||
| 742 | + | data, _ := json.MarshalIndent(receipt, "", " ") | |||
| 743 | + | if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil { | |||
| 744 | + | return fmt.Errorf("save agent consent: %w", err) | |||
| 745 | + | } | |||
| 746 | + | return nil | |||
| 747 | + | } | |||
| 748 | + | ||||
| 749 | + | func permissionSummary(p tools.PermissionSet) string { | |||
| 750 | + | var sb strings.Builder | |||
| 751 | + | if len(p.FS.Read) > 0 { | |||
| 752 | + | fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", ")) | |||
| 753 | + | } else { | |||
| 754 | + | fmt.Fprintln(&sb, " fs read: none") | |||
| 755 | + | } | |||
| 756 | + | if len(p.FS.Write) > 0 { | |||
| 757 | + | fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", ")) | |||
| 758 | + | } else { | |||
| 759 | + | fmt.Fprintln(&sb, " fs write: none") | |||
| 760 | + | } | |||
| 761 | + | mode := p.Bash.Mode | |||
| 762 | + | if mode == "" { | |||
| 763 | + | mode = "none" | |||
| 764 | + | } | |||
| 765 | + | fmt.Fprintf(&sb, " bash: %s", mode) | |||
| 766 | + | if len(p.Bash.Allow) > 0 { | |||
| 767 | + | fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", ")) | |||
| 768 | + | } | |||
| 769 | + | fmt.Fprintln(&sb) | |||
| 770 | + | if len(p.Net.Allow) > 0 { | |||
| 771 | + | fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", ")) | |||
| 772 | + | } | |||
| 773 | + | if len(p.Env.Read) > 0 { | |||
| 774 | + | fmt.Fprintf(&sb, " env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", ")) | |||
| 775 | + | } | |||
| 776 | + | return sb.String() | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | func bundledExtensionDirs(root string) []string { | |||
| 780 | + | entries, err := os.ReadDir(root) | |||
| 781 | + | if err != nil { | |||
| 782 | + | return nil | |||
| 783 | + | } | |||
| 784 | + | var out []string | |||
| 785 | + | for _, e := range entries { | |||
| 786 | + | if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) { | |||
| 787 | + | out = append(out, filepath.Join(root, e.Name())) | |||
| 788 | + | } | |||
| 789 | + | } | |||
| 790 | + | return out | |||
| 791 | + | } | |||
| 792 | + | ||||
| 793 | + | func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } | |||
| 794 | + | func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() } | |||
| 795 | + | ||||
| 796 | + | func safeAgentName(s string) string { | |||
| 797 | + | s = strings.TrimSpace(strings.ToLower(s)) | |||
| 798 | + | s = strings.ReplaceAll(s, "/", "_") | |||
| 799 | + | s = strings.ReplaceAll(s, "\\", "_") | |||
| 800 | + | s = strings.Map(func(r rune) rune { | |||
| 801 | + | if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { | |||
| 802 | + | return r | |||
| 803 | + | } | |||
| 804 | + | return '-' | |||
| 805 | + | }, s) | |||
| 806 | + | return strings.Trim(s, "-._") | |||
| 807 | + | } | |||
| 808 | + | ||||
| 809 | + | func digestDirectory(dir string) string { | |||
| 810 | + | h := sha256.New() | |||
| 811 | + | _ = writeCanonicalTar(dir, h) | |||
| 812 | + | return hex.EncodeToString(h.Sum(nil)) | |||
| 813 | + | } | |||
A packages/agent/zotfile_test.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "archive/tar" | |
| 5 | + | "bytes" | |
| 6 | + | "compress/gzip" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "net/url" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | ||
| 15 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func writeTestZotfile(t *testing.T, manifest string) string { | |
| 19 | + | t.Helper() | |
| 20 | + | dir := t.TempDir() | |
| 21 | + | if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | if err := os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte("Be useful."), 0o600); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | return dir | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) { | |
| 31 | + | for _, field := range []string{ | |
| 32 | + | `"net":{"allow":["example.com"]}`, | |
| 33 | + | `"env":{"read":["HOME"]}`, | |
| 34 | + | } { | |
| 35 | + | dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{`+field+`}}`) | |
| 36 | + | if _, _, err := loadZotfile(dir); err == nil { | |
| 37 | + | t.Fatalf("manifest with %s was accepted", field) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) { | |
| 43 | + | for _, name := range []string{"...", "Name", "two words", "a/b"} { | |
| 44 | + | dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`) | |
| 45 | + | if _, _, err := loadZotfile(dir); err == nil { | |
| 46 | + | t.Fatalf("unsafe manifest name %q was accepted", name) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestLoadZotfileRejectsBundledExecutableExtension(t *testing.T) { | |
| 52 | + | dir := writeTestZotfile(t, `{"zotfile":1,"name":"test"}`) | |
| 53 | + | ext := filepath.Join(dir, "extensions", "bad") | |
| 54 | + | if err := os.MkdirAll(ext, 0o700); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") { | |
| 61 | + | t.Fatalf("unexpected error: %v", err) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestCheckZotfileMinVersion(t *testing.T) { | |
| 66 | + | var zf zotfileLoaded | |
| 67 | + | zf.Manifest.Runtime.MinZot = "0.3.0" | |
| 68 | + | if err := checkZotfileRequirements(zf, "0.2.75"); err == nil { | |
| 69 | + | t.Fatal("old zot version accepted") | |
| 70 | + | } | |
| 71 | + | if err := checkZotfileRequirements(zf, "0.3.0"); err != nil { | |
| 72 | + | t.Fatalf("minimum version rejected: %v", err) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestApplyZotfileModelRequirementsRejectsUnsupportedFields(t *testing.T) { | |
| 77 | + | var m ZotfileManifest | |
| 78 | + | m.Model.MinTier = "frontier" | |
| 79 | + | if err := applyZotfileModelRequirements(&Args{}, m); err == nil { | |
| 80 | + | t.Fatal("unsupported min_tier was ignored") | |
| 81 | + | } | |
| 82 | + | m.Model.MinTier = "" | |
| 83 | + | m.Model.Requires = []string{"audio"} | |
| 84 | + | if err := applyZotfileModelRequirements(&Args{}, m); err == nil { | |
| 85 | + | t.Fatal("unsupported capability was ignored") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) { | |
| 90 | + | makeTar := func(name string, size int64) []byte { | |
| 91 | + | var buf bytes.Buffer | |
| 92 | + | tw := tar.NewWriter(&buf) | |
| 93 | + | if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | _ = tw.Close() | |
| 97 | + | return buf.Bytes() | |
| 98 | + | } | |
| 99 | + | if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil { | |
| 100 | + | t.Fatal("path traversal accepted") | |
| 101 | + | } | |
| 102 | + | if err := untar(bytes.NewReader(makeTar("large", maxZotfileEntrySize+1)), t.TempDir()); err == nil { | |
| 103 | + | t.Fatal("oversized entry accepted") | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestParseGitHubAgentURL(t *testing.T) { | |
| 108 | + | tests := []struct { | |
| 109 | + | input string | |
| 110 | + | owner, repo, ref, dir string | |
| 111 | + | }{ | |
| 112 | + | {"https://github.com/patriceckhart/agents/zot-maintenance", "patriceckhart", "agents", "HEAD", "zot-maintenance"}, | |
| 113 | + | {"https://github.com/patriceckhart/agents/tree/main/zot-maintenance", "patriceckhart", "agents", "main", "zot-maintenance"}, | |
| 114 | + | {"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""}, | |
| 115 | + | } | |
| 116 | + | for _, tt := range tests { | |
| 117 | + | u, err := url.Parse(tt.input) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | owner, repo, ref, dir, err := parseGitHubAgentURL(u) | |
| 122 | + | if err != nil { | |
| 123 | + | t.Fatalf("parse %s: %v", tt.input, err) | |
| 124 | + | } | |
| 125 | + | if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir { | |
| 126 | + | t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) { | |
| 132 | + | var archive bytes.Buffer | |
| 133 | + | gz := gzip.NewWriter(&archive) | |
| 134 | + | tw := tar.NewWriter(gz) | |
| 135 | + | files := map[string]string{ | |
| 136 | + | "agents-main/zot-maintenance/manifest.json": `{"zotfile":1,"name":"zot-maintenance"}`, | |
| 137 | + | "agents-main/zot-maintenance/AGENT.md": "Maintain zot.", | |
| 138 | + | } | |
| 139 | + | for name, content := range files { | |
| 140 | + | if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil { | |
| 141 | + | t.Fatal(err) | |
| 142 | + | } | |
| 143 | + | if _, err := tw.Write([]byte(content)); err != nil { | |
| 144 | + | t.Fatal(err) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | if err := tw.Close(); err != nil { | |
| 148 | + | t.Fatal(err) | |
| 149 | + | } | |
| 150 | + | if err := gz.Close(); err != nil { | |
| 151 | + | t.Fatal(err) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 155 | + | w.Header().Set("Content-Type", "application/gzip") | |
| 156 | + | _, _ = w.Write(archive.Bytes()) | |
| 157 | + | })) | |
| 158 | + | defer server.Close() | |
| 159 | + | oldArchiveURL := githubArchiveURL | |
| 160 | + | githubArchiveURL = func(_, _, _ string) string { return server.URL } | |
| 161 | + | t.Cleanup(func() { githubArchiveURL = oldArchiveURL }) | |
| 162 | + | ||
| 163 | + | u, _ := url.Parse("https://github.com/patriceckhart/agents/zot-maintenance") | |
| 164 | + | zf, cleanup, err := loadRemoteZotfile(u) | |
| 165 | + | if err != nil { | |
| 166 | + | t.Fatal(err) | |
| 167 | + | } | |
| 168 | + | if zf.Manifest.Name != "zot-maintenance" || !zf.Temp { | |
| 169 | + | t.Fatalf("unexpected zotfile: %+v", zf) | |
| 170 | + | } | |
| 171 | + | root := filepath.Dir(filepath.Dir(zf.Dir)) | |
| 172 | + | cleanup() | |
| 173 | + | if _, err := os.Stat(root); !os.IsNotExist(err) { | |
| 174 | + | t.Fatalf("temporary checkout was not removed: %v", err) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestPermissionSummaryShowsDeniedScopes(t *testing.T) { | |
| 179 | + | got := permissionSummary(tools.PermissionSet{}) | |
| 180 | + | if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") { | |
| 181 | + | t.Fatalf("summary did not show denied scopes:\n%s", got) | |
| 182 | + | } | |
| 183 | + | } | |
| @@ -0,0 +1,183 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "archive/tar" | |||
| 5 | + | "bytes" | |||
| 6 | + | "compress/gzip" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "net/url" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "strings" | |||
| 13 | + | "testing" | |||
| 14 | + | ||||
| 15 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func writeTestZotfile(t *testing.T, manifest string) string { | |||
| 19 | + | t.Helper() | |||
| 20 | + | dir := t.TempDir() | |||
| 21 | + | if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | if err := os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte("Be useful."), 0o600); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | return dir | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) { | |||
| 31 | + | for _, field := range []string{ | |||
| 32 | + | `"net":{"allow":["example.com"]}`, | |||
| 33 | + | `"env":{"read":["HOME"]}`, | |||
| 34 | + | } { | |||
| 35 | + | dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{`+field+`}}`) | |||
| 36 | + | if _, _, err := loadZotfile(dir); err == nil { | |||
| 37 | + | t.Fatalf("manifest with %s was accepted", field) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) { | |||
| 43 | + | for _, name := range []string{"...", "Name", "two words", "a/b"} { | |||
| 44 | + | dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`) | |||
| 45 | + | if _, _, err := loadZotfile(dir); err == nil { | |||
| 46 | + | t.Fatalf("unsafe manifest name %q was accepted", name) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestLoadZotfileRejectsBundledExecutableExtension(t *testing.T) { | |||
| 52 | + | dir := writeTestZotfile(t, `{"zotfile":1,"name":"test"}`) | |||
| 53 | + | ext := filepath.Join(dir, "extensions", "bad") | |||
| 54 | + | if err := os.MkdirAll(ext, 0o700); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") { | |||
| 61 | + | t.Fatalf("unexpected error: %v", err) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestCheckZotfileMinVersion(t *testing.T) { | |||
| 66 | + | var zf zotfileLoaded | |||
| 67 | + | zf.Manifest.Runtime.MinZot = "0.3.0" | |||
| 68 | + | if err := checkZotfileRequirements(zf, "0.2.75"); err == nil { | |||
| 69 | + | t.Fatal("old zot version accepted") | |||
| 70 | + | } | |||
| 71 | + | if err := checkZotfileRequirements(zf, "0.3.0"); err != nil { | |||
| 72 | + | t.Fatalf("minimum version rejected: %v", err) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestApplyZotfileModelRequirementsRejectsUnsupportedFields(t *testing.T) { | |||
| 77 | + | var m ZotfileManifest | |||
| 78 | + | m.Model.MinTier = "frontier" | |||
| 79 | + | if err := applyZotfileModelRequirements(&Args{}, m); err == nil { | |||
| 80 | + | t.Fatal("unsupported min_tier was ignored") | |||
| 81 | + | } | |||
| 82 | + | m.Model.MinTier = "" | |||
| 83 | + | m.Model.Requires = []string{"audio"} | |||
| 84 | + | if err := applyZotfileModelRequirements(&Args{}, m); err == nil { | |||
| 85 | + | t.Fatal("unsupported capability was ignored") | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) { | |||
| 90 | + | makeTar := func(name string, size int64) []byte { | |||
| 91 | + | var buf bytes.Buffer | |||
| 92 | + | tw := tar.NewWriter(&buf) | |||
| 93 | + | if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | _ = tw.Close() | |||
| 97 | + | return buf.Bytes() | |||
| 98 | + | } | |||
| 99 | + | if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil { | |||
| 100 | + | t.Fatal("path traversal accepted") | |||
| 101 | + | } | |||
| 102 | + | if err := untar(bytes.NewReader(makeTar("large", maxZotfileEntrySize+1)), t.TempDir()); err == nil { | |||
| 103 | + | t.Fatal("oversized entry accepted") | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestParseGitHubAgentURL(t *testing.T) { | |||
| 108 | + | tests := []struct { | |||
| 109 | + | input string | |||
| 110 | + | owner, repo, ref, dir string | |||
| 111 | + | }{ | |||
| 112 | + | {"https://github.com/patriceckhart/agents/zot-maintenance", "patriceckhart", "agents", "HEAD", "zot-maintenance"}, | |||
| 113 | + | {"https://github.com/patriceckhart/agents/tree/main/zot-maintenance", "patriceckhart", "agents", "main", "zot-maintenance"}, | |||
| 114 | + | {"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""}, | |||
| 115 | + | } | |||
| 116 | + | for _, tt := range tests { | |||
| 117 | + | u, err := url.Parse(tt.input) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | owner, repo, ref, dir, err := parseGitHubAgentURL(u) | |||
| 122 | + | if err != nil { | |||
| 123 | + | t.Fatalf("parse %s: %v", tt.input, err) | |||
| 124 | + | } | |||
| 125 | + | if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir { | |||
| 126 | + | t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) { | |||
| 132 | + | var archive bytes.Buffer | |||
| 133 | + | gz := gzip.NewWriter(&archive) | |||
| 134 | + | tw := tar.NewWriter(gz) | |||
| 135 | + | files := map[string]string{ | |||
| 136 | + | "agents-main/zot-maintenance/manifest.json": `{"zotfile":1,"name":"zot-maintenance"}`, | |||
| 137 | + | "agents-main/zot-maintenance/AGENT.md": "Maintain zot.", | |||
| 138 | + | } | |||
| 139 | + | for name, content := range files { | |||
| 140 | + | if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil { | |||
| 141 | + | t.Fatal(err) | |||
| 142 | + | } | |||
| 143 | + | if _, err := tw.Write([]byte(content)); err != nil { | |||
| 144 | + | t.Fatal(err) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | if err := tw.Close(); err != nil { | |||
| 148 | + | t.Fatal(err) | |||
| 149 | + | } | |||
| 150 | + | if err := gz.Close(); err != nil { | |||
| 151 | + | t.Fatal(err) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 155 | + | w.Header().Set("Content-Type", "application/gzip") | |||
| 156 | + | _, _ = w.Write(archive.Bytes()) | |||
| 157 | + | })) | |||
| 158 | + | defer server.Close() | |||
| 159 | + | oldArchiveURL := githubArchiveURL | |||
| 160 | + | githubArchiveURL = func(_, _, _ string) string { return server.URL } | |||
| 161 | + | t.Cleanup(func() { githubArchiveURL = oldArchiveURL }) | |||
| 162 | + | ||||
| 163 | + | u, _ := url.Parse("https://github.com/patriceckhart/agents/zot-maintenance") | |||
| 164 | + | zf, cleanup, err := loadRemoteZotfile(u) | |||
| 165 | + | if err != nil { | |||
| 166 | + | t.Fatal(err) | |||
| 167 | + | } | |||
| 168 | + | if zf.Manifest.Name != "zot-maintenance" || !zf.Temp { | |||
| 169 | + | t.Fatalf("unexpected zotfile: %+v", zf) | |||
| 170 | + | } | |||
| 171 | + | root := filepath.Dir(filepath.Dir(zf.Dir)) | |||
| 172 | + | cleanup() | |||
| 173 | + | if _, err := os.Stat(root); !os.IsNotExist(err) { | |||
| 174 | + | t.Fatalf("temporary checkout was not removed: %v", err) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestPermissionSummaryShowsDeniedScopes(t *testing.T) { | |||
| 179 | + | got := permissionSummary(tools.PermissionSet{}) | |||
| 180 | + | if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") { | |||
| 181 | + | t.Fatalf("summary did not show denied scopes:\n%s", got) | |||
| 182 | + | } | |||
| 183 | + | } | |||
M packages/provider/github_copilot.go +5 -4
| @@ -190,9 +190,10 @@ func NewGithubCopilotClient(pat string) Client { | |||
| 190 | 190 | // Initial baseURL is a sane default; copilotRefreshTransport rewrites | |
| 191 | 191 | // the host on every request based on the freshly-issued token. | |
| 192 | 192 | return &openaiClient{ | |
| 193 | - | apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity | |
| 194 | - | baseURL: "https://api.individual.githubcopilot.com", | |
| 195 | - | name: "github-copilot", | |
| 196 | - | http: httpClient, | |
| 193 | + | apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity | |
| 194 | + | baseURL: "https://api.individual.githubcopilot.com", | |
| 195 | + | chatCompletionsPath: "/chat/completions", | |
| 196 | + | name: "github-copilot", | |
| 197 | + | http: httpClient, | |
| 197 | 198 | } | |
| 198 | 199 | } | |
| @@ -190,9 +190,10 @@ func NewGithubCopilotClient(pat string) Client { | |||||
| 190 | // Initial baseURL is a sane default; copilotRefreshTransport rewrites | 190 | // Initial baseURL is a sane default; copilotRefreshTransport rewrites | ||
| 191 | // the host on every request based on the freshly-issued token. | 191 | // the host on every request based on the freshly-issued token. | ||
| 192 | return &openaiClient{ | 192 | return &openaiClient{ | ||
| 193 | - | apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity | 193 | + | apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity |
| 194 | - | baseURL: "https://api.individual.githubcopilot.com", | 194 | + | baseURL: "https://api.individual.githubcopilot.com", |
| 195 | - | name: "github-copilot", | 195 | + | chatCompletionsPath: "/chat/completions", |
| 196 | - | http: httpClient, | 196 | + | name: "github-copilot", |
| 197 | + | http: httpClient, | |||
| 197 | } | 198 | } | ||
| 198 | } | 199 | } | ||
M packages/provider/google_vertex.go +10 -2
| @@ -311,7 +311,11 @@ func (t *vertexTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |||
| 311 | 311 | newPath := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/google/models/%s%s", | |
| 312 | 312 | t.cfg.project, t.cfg.location, modelID, verb) | |
| 313 | 313 | clone := req.Clone(req.Context()) | |
| 314 | - | clone.URL.Host = t.cfg.location + "-aiplatform.googleapis.com" | |
| 314 | + | if t.cfg.location == "global" { | |
| 315 | + | clone.URL.Host = "aiplatform.googleapis.com" | |
| 316 | + | } else { | |
| 317 | + | clone.URL.Host = t.cfg.location + "-aiplatform.googleapis.com" | |
| 318 | + | } | |
| 315 | 319 | clone.URL.Path = newPath | |
| 316 | 320 | // Drop existing auth header and pick the right one. | |
| 317 | 321 | clone.Header.Del("x-goog-api-key") | |
| @@ -336,9 +340,13 @@ func NewVertex(_ string, _ string) Client { | |||
| 336 | 340 | if err != nil { | |
| 337 | 341 | return &unimplementedClient{name: "google-vertex", hint: err.Error()} | |
| 338 | 342 | } | |
| 343 | + | baseHost := "aiplatform.googleapis.com" | |
| 344 | + | if cfg.location != "global" { | |
| 345 | + | baseHost = cfg.location + "-aiplatform.googleapis.com" | |
| 346 | + | } | |
| 339 | 347 | inner := &geminiClient{ | |
| 340 | 348 | apiKey: "vertex-placeholder", // overwritten by transport | |
| 341 | - | baseURL: "https://" + cfg.location + "-aiplatform.googleapis.com", | |
| 349 | + | baseURL: "https://" + baseHost, | |
| 342 | 350 | http: &http.Client{ | |
| 343 | 351 | Transport: &vertexTransport{inner: http.DefaultTransport, cfg: cfg}, | |
| 344 | 352 | Timeout: 0, | |
| @@ -311,7 +311,11 @@ func (t *vertexTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |||||
| 311 | newPath := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/google/models/%s%s", | 311 | newPath := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/google/models/%s%s", | ||
| 312 | t.cfg.project, t.cfg.location, modelID, verb) | 312 | t.cfg.project, t.cfg.location, modelID, verb) | ||
| 313 | clone := req.Clone(req.Context()) | 313 | clone := req.Clone(req.Context()) | ||
| 314 | - | clone.URL.Host = t.cfg.location + "-aiplatform.googleapis.com" | 314 | + | if t.cfg.location == "global" { |
| 315 | + | clone.URL.Host = "aiplatform.googleapis.com" | |||
| 316 | + | } else { | |||
| 317 | + | clone.URL.Host = t.cfg.location + "-aiplatform.googleapis.com" | |||
| 318 | + | } | |||
| 315 | clone.URL.Path = newPath | 319 | clone.URL.Path = newPath | ||
| 316 | // Drop existing auth header and pick the right one. | 320 | // Drop existing auth header and pick the right one. | ||
| 317 | clone.Header.Del("x-goog-api-key") | 321 | clone.Header.Del("x-goog-api-key") | ||
| @@ -336,9 +340,13 @@ func NewVertex(_ string, _ string) Client { | |||||
| 336 | if err != nil { | 340 | if err != nil { | ||
| 337 | return &unimplementedClient{name: "google-vertex", hint: err.Error()} | 341 | return &unimplementedClient{name: "google-vertex", hint: err.Error()} | ||
| 338 | } | 342 | } | ||
| 343 | + | baseHost := "aiplatform.googleapis.com" | |||
| 344 | + | if cfg.location != "global" { | |||
| 345 | + | baseHost = cfg.location + "-aiplatform.googleapis.com" | |||
| 346 | + | } | |||
| 339 | inner := &geminiClient{ | 347 | inner := &geminiClient{ | ||
| 340 | apiKey: "vertex-placeholder", // overwritten by transport | 348 | apiKey: "vertex-placeholder", // overwritten by transport | ||
| 341 | - | baseURL: "https://" + cfg.location + "-aiplatform.googleapis.com", | 349 | + | baseURL: "https://" + baseHost, |
| 342 | http: &http.Client{ | 350 | http: &http.Client{ | ||
| 343 | Transport: &vertexTransport{inner: http.DefaultTransport, cfg: cfg}, | 351 | Transport: &vertexTransport{inner: http.DefaultTransport, cfg: cfg}, | ||
| 344 | Timeout: 0, | 352 | Timeout: 0, | ||
M packages/provider/google_vertex_test.go +50 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package provider | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "net/http" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "testing" | |
| @@ -40,6 +41,55 @@ func TestVertexConfigParsesAuthorizedUser(t *testing.T) { | |||
| 40 | 41 | } | |
| 41 | 42 | } | |
| 42 | 43 | ||
| 44 | + | type vertexRoundTripFunc func(*http.Request) (*http.Response, error) | |
| 45 | + | ||
| 46 | + | func (f vertexRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |
| 47 | + | return f(req) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestVertexTransportEndpoint(t *testing.T) { | |
| 51 | + | tests := []struct { | |
| 52 | + | name string | |
| 53 | + | location string | |
| 54 | + | wantHost string | |
| 55 | + | }{ | |
| 56 | + | {name: "global", location: "global", wantHost: "aiplatform.googleapis.com"}, | |
| 57 | + | {name: "regional", location: "us-central1", wantHost: "us-central1-aiplatform.googleapis.com"}, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | var got *http.Request | |
| 63 | + | transport := &vertexTransport{ | |
| 64 | + | cfg: &vertexConfig{ | |
| 65 | + | project: "test-project", | |
| 66 | + | location: tt.location, | |
| 67 | + | apiKey: "test-key", | |
| 68 | + | }, | |
| 69 | + | inner: vertexRoundTripFunc(func(req *http.Request) (*http.Response, error) { | |
| 70 | + | got = req | |
| 71 | + | return &http.Response{StatusCode: http.StatusOK}, nil | |
| 72 | + | }), | |
| 73 | + | } | |
| 74 | + | req, err := http.NewRequest(http.MethodPost, "https://placeholder.example/v1beta/models/gemini-test:streamGenerateContent?alt=sse", nil) | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | if _, err := transport.RoundTrip(req); err != nil { | |
| 80 | + | t.Fatalf("RoundTrip: %v", err) | |
| 81 | + | } | |
| 82 | + | if got.URL.Host != tt.wantHost { | |
| 83 | + | t.Errorf("host = %q, want %q", got.URL.Host, tt.wantHost) | |
| 84 | + | } | |
| 85 | + | wantPath := "/v1/projects/test-project/locations/" + tt.location + "/publishers/google/models/gemini-test:streamGenerateContent" | |
| 86 | + | if got.URL.Path != wantPath { | |
| 87 | + | t.Errorf("path = %q, want %q", got.URL.Path, wantPath) | |
| 88 | + | } | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 43 | 93 | func TestVertexConfigRejectsBadType(t *testing.T) { | |
| 44 | 94 | tmp := t.TempDir() | |
| 45 | 95 | path := filepath.Join(tmp, "adc.json") | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package provider | 1 | package provider | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "net/http" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -40,6 +41,55 @@ func TestVertexConfigParsesAuthorizedUser(t *testing.T) { | |||||
| 40 | } | 41 | } | ||
| 41 | } | 42 | } | ||
| 42 | 43 | ||||
| 44 | + | type vertexRoundTripFunc func(*http.Request) (*http.Response, error) | |||
| 45 | + | ||||
| 46 | + | func (f vertexRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |||
| 47 | + | return f(req) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestVertexTransportEndpoint(t *testing.T) { | |||
| 51 | + | tests := []struct { | |||
| 52 | + | name string | |||
| 53 | + | location string | |||
| 54 | + | wantHost string | |||
| 55 | + | }{ | |||
| 56 | + | {name: "global", location: "global", wantHost: "aiplatform.googleapis.com"}, | |||
| 57 | + | {name: "regional", location: "us-central1", wantHost: "us-central1-aiplatform.googleapis.com"}, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | var got *http.Request | |||
| 63 | + | transport := &vertexTransport{ | |||
| 64 | + | cfg: &vertexConfig{ | |||
| 65 | + | project: "test-project", | |||
| 66 | + | location: tt.location, | |||
| 67 | + | apiKey: "test-key", | |||
| 68 | + | }, | |||
| 69 | + | inner: vertexRoundTripFunc(func(req *http.Request) (*http.Response, error) { | |||
| 70 | + | got = req | |||
| 71 | + | return &http.Response{StatusCode: http.StatusOK}, nil | |||
| 72 | + | }), | |||
| 73 | + | } | |||
| 74 | + | req, err := http.NewRequest(http.MethodPost, "https://placeholder.example/v1beta/models/gemini-test:streamGenerateContent?alt=sse", nil) | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | if _, err := transport.RoundTrip(req); err != nil { | |||
| 80 | + | t.Fatalf("RoundTrip: %v", err) | |||
| 81 | + | } | |||
| 82 | + | if got.URL.Host != tt.wantHost { | |||
| 83 | + | t.Errorf("host = %q, want %q", got.URL.Host, tt.wantHost) | |||
| 84 | + | } | |||
| 85 | + | wantPath := "/v1/projects/test-project/locations/" + tt.location + "/publishers/google/models/gemini-test:streamGenerateContent" | |||
| 86 | + | if got.URL.Path != wantPath { | |||
| 87 | + | t.Errorf("path = %q, want %q", got.URL.Path, wantPath) | |||
| 88 | + | } | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 43 | func TestVertexConfigRejectsBadType(t *testing.T) { | 93 | func TestVertexConfigRejectsBadType(t *testing.T) { | ||
| 44 | tmp := t.TempDir() | 94 | tmp := t.TempDir() | ||
| 45 | path := filepath.Join(tmp, "adc.json") | 95 | path := filepath.Join(tmp, "adc.json") | ||
M packages/provider/openai.go +15 -7
| @@ -35,12 +35,13 @@ func chatCompletionsURL(baseURL string) string { | |||
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | type openaiClient struct { | |
| 38 | - | apiKey string | |
| 39 | - | baseURL string | |
| 40 | - | name string | |
| 41 | - | oauth bool // when true, apiKey actually holds an OAuth access token | |
| 42 | - | headers map[string]string | |
| 43 | - | http *http.Client | |
| 38 | + | apiKey string | |
| 39 | + | baseURL string | |
| 40 | + | chatCompletionsPath string | |
| 41 | + | name string | |
| 42 | + | oauth bool // when true, apiKey actually holds an OAuth access token | |
| 43 | + | headers map[string]string | |
| 44 | + | http *http.Client | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | 47 | // NewOpenAI creates an OpenAI client using an API key. baseURL may be empty. | |
| @@ -431,8 +432,15 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} { | |||
| 431 | 432 | ||
| 432 | 433 | // ---- streaming ---- | |
| 433 | 434 | ||
| 435 | + | func (c *openaiClient) chatCompletionsURL() string { | |
| 436 | + | if c.chatCompletionsPath != "" { | |
| 437 | + | return strings.TrimRight(c.baseURL, "/") + "/" + strings.TrimLeft(c.chatCompletionsPath, "/") | |
| 438 | + | } | |
| 439 | + | return chatCompletionsURL(c.baseURL) | |
| 440 | + | } | |
| 441 | + | ||
| 434 | 442 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 435 | - | endpoint := chatCompletionsURL(c.baseURL) | |
| 443 | + | endpoint := c.chatCompletionsURL() | |
| 436 | 444 | wire, err := c.buildRequest(req) | |
| 437 | 445 | if err != nil { | |
| 438 | 446 | return nil, err | |
| @@ -35,12 +35,13 @@ func chatCompletionsURL(baseURL string) string { | |||||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | type openaiClient struct { | 37 | type openaiClient struct { | ||
| 38 | - | apiKey string | 38 | + | apiKey string |
| 39 | - | baseURL string | 39 | + | baseURL string |
| 40 | - | name string | 40 | + | chatCompletionsPath string |
| 41 | - | oauth bool // when true, apiKey actually holds an OAuth access token | 41 | + | name string |
| 42 | - | headers map[string]string | 42 | + | oauth bool // when true, apiKey actually holds an OAuth access token |
| 43 | - | http *http.Client | 43 | + | headers map[string]string |
| 44 | + | http *http.Client | |||
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| 46 | // NewOpenAI creates an OpenAI client using an API key. baseURL may be empty. | 47 | // NewOpenAI creates an OpenAI client using an API key. baseURL may be empty. | ||
| @@ -431,8 +432,15 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} { | |||||
| 431 | 432 | ||||
| 432 | // ---- streaming ---- | 433 | // ---- streaming ---- | ||
| 433 | 434 | ||||
| 435 | + | func (c *openaiClient) chatCompletionsURL() string { | |||
| 436 | + | if c.chatCompletionsPath != "" { | |||
| 437 | + | return strings.TrimRight(c.baseURL, "/") + "/" + strings.TrimLeft(c.chatCompletionsPath, "/") | |||
| 438 | + | } | |||
| 439 | + | return chatCompletionsURL(c.baseURL) | |||
| 440 | + | } | |||
| 441 | + | ||||
| 434 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 442 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 435 | - | endpoint := chatCompletionsURL(c.baseURL) | 443 | + | endpoint := c.chatCompletionsURL() |
| 436 | wire, err := c.buildRequest(req) | 444 | wire, err := c.buildRequest(req) | ||
| 437 | if err != nil { | 445 | if err != nil { | ||
| 438 | return nil, err | 446 | return nil, err | ||
M packages/provider/openai_url_test.go +12 -0
| @@ -38,3 +38,15 @@ func TestChatCompletionsURL(t *testing.T) { | |||
| 38 | 38 | }) | |
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | + | ||
| 42 | + | func TestGithubCopilotChatCompletionsURL(t *testing.T) { | |
| 43 | + | client, ok := NewGithubCopilotClient("test-token").(*openaiClient) | |
| 44 | + | if !ok { | |
| 45 | + | t.Fatal("NewGithubCopilotClient did not return an openaiClient") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | const want = "https://api.individual.githubcopilot.com/chat/completions" | |
| 49 | + | if got := client.chatCompletionsURL(); got != want { | |
| 50 | + | t.Errorf("GitHub Copilot chat completions URL = %q, want %q", got, want) | |
| 51 | + | } | |
| 52 | + | } | |
| @@ -38,3 +38,15 @@ func TestChatCompletionsURL(t *testing.T) { | |||||
| 38 | }) | 38 | }) | ||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| 41 | + | ||||
| 42 | + | func TestGithubCopilotChatCompletionsURL(t *testing.T) { | |||
| 43 | + | client, ok := NewGithubCopilotClient("test-token").(*openaiClient) | |||
| 44 | + | if !ok { | |||
| 45 | + | t.Fatal("NewGithubCopilotClient did not return an openaiClient") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | const want = "https://api.individual.githubcopilot.com/chat/completions" | |||
| 49 | + | if got := client.chatCompletionsURL(); got != want { | |||
| 50 | + | t.Errorf("GitHub Copilot chat completions URL = %q, want %q", got, want) | |||
| 51 | + | } | |||
| 52 | + | } | |||