detached 60e709e

Merge branch 'main' into fix/editproperly

commit
60e709ebd194ce211ec304f06c66292594d9096d
author
Patric Eckhart · · 2026-07-13 08:29 UTC
committer
GitHub · · 2026-07-13 08:29 UTC
merge of
ec2512f + 1b17517 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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.
2626 - user and extension themes via JSON; see [docs/themes.md](docs/themes.md).
2727 - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd).
2828 - 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).
2930
3031 ## Install
3132
@@ -211,6 +212,18 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se
211212 - **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).
212213 - **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.
213214
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+
214227 ## Embedding
215228
216229 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 ## Install31 ## 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 ## Embedding227 ## 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
55 require (
66 github.com/alecthomas/chroma/v2 v2.23.1
77 github.com/google/uuid v1.6.0
8+ github.com/klauspost/compress v1.19.0
89 github.com/mattn/go-runewidth v0.0.16
910 github.com/sahilm/fuzzy v0.1.1
1011 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.16 github.com/alecthomas/chroma/v2 v2.23.1
7 github.com/google/uuid v1.6.07 github.com/google/uuid v1.6.0
8+ github.com/klauspost/compress v1.19.0
8 github.com/mattn/go-runewidth v0.0.169 github.com/mattn/go-runewidth v0.0.16
9 github.com/sahilm/fuzzy v0.1.110 github.com/sahilm/fuzzy v0.1.1
10 golang.org/x/image v0.43.011 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 (
66 "strconv"
77 "strings"
88
9+ "github.com/patriceckhart/zot/packages/agent/tools"
910 "github.com/patriceckhart/zot/packages/tui"
1011 "golang.org/x/term"
1112 )
@@ -100,6 +101,13 @@ type Args struct {
100101 // swarm-spawned agent. Empty in every other mode. Set by
101102 // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.
102103 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
103111 }
104112
105113 // 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 by101 // 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 string103 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) {
528528 }
529529
530530 sandbox := tools.NewSandbox(args.CWD)
531+ if args.PermissionSet != nil {
532+ sandbox.SetPermissions(args.PermissionSet)
533+ }
531534 reg := buildToolRegistry(args, args.CWD, sandbox)
532535
533536 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
@@ -877,6 +880,9 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
877880 if s == nil || r == nil {
878881 return
879882 }
883+ if r.Sandbox != nil && r.Sandbox.Permissions != nil {
884+ s.Permissions = r.Sandbox.Permissions
885+ }
880886 r.Sandbox = s
881887 for name, t := range r.ToolRegistry {
882888 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 return881 return
879 }882 }
883+ if r.Sandbox != nil && r.Sandbox.Permissions != nil {
884+ s.Permissions = r.Sandbox.Permissions
885+ }
880 r.Sandbox = s886 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 {
189189 if handled, err := runUpdateCommand(rawArgs, version); handled {
190190 return err
191191 }
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 {
192199 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
193200 // spawn the binary with a clean argv. Strip the leading 'rpc'
194201 // token and let the rest flow through the normal arg parser.
@@ -211,39 +218,18 @@ func Run(rawArgs []string, version string) error {
211218 }
212219 // Model catalog: load any cached discovery data before we inspect
213220 // 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()
235222
236223 if args.ListModels {
237224 printModels()
238225 return nil
239226 }
240227
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+}
246230
231+func runWithArgs(args Args, version string) error {
232+ ctx := context.Background()
247233 switch args.Mode {
248234 case ModePrint:
249235 return runPrintMode(ctx, args, version)
@@ -835,6 +821,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
835821 wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
836822 args.CWD = absPath
837823 r.CWD = absPath
824+ if args.PermissionSet != nil {
825+ expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
826+ args.PermissionSet = &expanded
827+ }
838828 if sharedSandbox != nil {
839829 sharedSandbox.Root = absPath
840830 if wasJailed {
@@ -857,8 +847,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
857847 // because /cd's semantics are "start fresh here", matching
858848 // what relaunching `zot --cwd <path>` would do today.
859849 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)
862853 if serr != nil {
863854 return fmt.Errorf("open session in %s: %v", absPath, serr)
864855 }
@@ -1149,6 +1140,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11491140 return runErr
11501141 }
11511142
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+
11521150 // openOrCreateSession returns a session for the run. sess may be nil
11531151 // with a nil error if session persistence is disabled.
11541152 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)
11581156 // Sweep meta-only files left over from older zot versions (and from
11591157 // any session that crashed before its first AppendMessage). Cheap;
11601158 // 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)
11621161 var (
11631162 s *core.Session
11641163 msgs []provider.Message
@@ -1180,12 +1179,12 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
11801179 msgs = nil
11811180 }
11821181 case args.Continue:
1183- latest := core.LatestSession(ZotHome(), args.CWD)
1182+ latest := core.LatestSession(sessionsRoot, args.CWD)
11841183 if latest != "" {
11851184 s, msgs, err = core.OpenSession(latest)
11861185 }
11871186 case args.Resume:
1188- picked, perr := pickSession(args.CWD)
1187+ picked, perr := pickSession(sessionsRoot, args.CWD)
11891188 if perr != nil {
11901189 return nil, perr
11911190 }
@@ -1204,11 +1203,11 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
12041203 }
12051204 return s, nil
12061205 }
1207- return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
1206+ return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
12081207 }
12091208
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)
12121211 if len(files) == 0 {
12131212 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
12141213 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 err190 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 can199 // `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 inspect219 // 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 nil225 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 = absPath822 args.CWD = absPath
837 r.CWD = absPath823 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 = absPath829 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", matching847 // 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 runErr1140 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 nil1150 // 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 from1156 // 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.Session1162 s *core.Session
1164 msgs []provider.Message1163 msgs []provider.Message
@@ -1180,12 +1179,12 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1180 msgs = nil1179 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, perr1189 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, nil1204 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 "", nil1213 return "", nil
M packages/agent/skills/skills.go +5 -0
@@ -230,6 +230,11 @@ func searchDirs(zotHome, cwd, userHome string) []location {
230230 }
231231 out = append(out, location{dir: dir, label: label})
232232 }
233+ if extra := os.Getenv("ZOT_AGENT_SKILLS"); extra != "" {
234+ for _, dir := range filepath.SplitList(extra) {
235+ add(dir, "agent")
236+ }
237+ }
233238 if cwd != "" {
234239 add(filepath.Join(cwd, ".zot", "skills"), "project")
235240 }
@@ -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
5252 if err := t.Sandbox.CheckCommand(a.Command); err != nil {
5353 return core.ToolResult{}, err
5454 }
55+ if err := t.Sandbox.CheckBashPermission(a.Command); err != nil {
56+ return core.ToolResult{}, err
57+ }
5558 cwd := t.CWD
5659 if cwd == "" {
5760 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{}, err53 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.CWD58 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
4848 return core.ToolResult{}, fmt.Errorf("at least one edit is required")
4949 }
5050 path := resolvePath(t.CWD, a.Path)
51- if err := t.Sandbox.CheckPath(path); err != nil {
51+ if err := t.Sandbox.CheckWritePath(path); err != nil {
5252 return core.ToolResult{}, err
5353 }
5454
@@ -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{}, err52 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
4949 return core.ToolResult{}, fmt.Errorf("path is required")
5050 }
5151 path := resolvePath(t.CWD, a.Path)
52- if err := t.Sandbox.CheckPath(path); err != nil {
52+ if err := t.Sandbox.CheckReadPath(path); err != nil {
5353 return core.ToolResult{}, err
5454 }
5555 // 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{}, err53 return core.ToolResult{}, err
54 }54 }
55 // What the model sees in errors: relative to the sandbox root when55 // 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 (
1515 // The value is designed to be shared across tool instances (by pointer).
1616 // Enable/Disable are atomic so they can be toggled from the TUI.
1717 type Sandbox struct {
18- Root string
19- locked atomic.Bool
18+ Root string
19+ Permissions *PermissionSet
20+ locked atomic.Bool
2021 }
2122
2223 // 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 string18+ Root string
19- locked atomic.Bool19+ 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
4040 return core.ToolResult{}, fmt.Errorf("path is required")
4141 }
4242 path := resolvePath(t.CWD, a.Path)
43- if err := t.Sandbox.CheckPath(path); err != nil {
43+ if err := t.Sandbox.CheckWritePath(path); err != nil {
4444 return core.ToolResult{}, err
4545 }
4646
@@ -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{}, err44 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 {
190190 // Initial baseURL is a sane default; copilotRefreshTransport rewrites
191191 // the host on every request based on the freshly-issued token.
192192 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,
197198 }
198199 }
@@ -190,9 +190,10 @@ func NewGithubCopilotClient(pat string) Client {
190 // Initial baseURL is a sane default; copilotRefreshTransport rewrites190 // 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 parity193+ 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) {
311311 newPath := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/google/models/%s%s",
312312 t.cfg.project, t.cfg.location, modelID, verb)
313313 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+ }
315319 clone.URL.Path = newPath
316320 // Drop existing auth header and pick the right one.
317321 clone.Header.Del("x-goog-api-key")
@@ -336,9 +340,13 @@ func NewVertex(_ string, _ string) Client {
336340 if err != nil {
337341 return &unimplementedClient{name: "google-vertex", hint: err.Error()}
338342 }
343+ baseHost := "aiplatform.googleapis.com"
344+ if cfg.location != "global" {
345+ baseHost = cfg.location + "-aiplatform.googleapis.com"
346+ }
339347 inner := &geminiClient{
340348 apiKey: "vertex-placeholder", // overwritten by transport
341- baseURL: "https://" + cfg.location + "-aiplatform.googleapis.com",
349+ baseURL: "https://" + baseHost,
342350 http: &http.Client{
343351 Transport: &vertexTransport{inner: http.DefaultTransport, cfg: cfg},
344352 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 = newPath319 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 transport348 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 @@
11 package provider
22
33 import (
4+ "net/http"
45 "os"
56 "path/filepath"
67 "testing"
@@ -40,6 +41,55 @@ func TestVertexConfigParsesAuthorizedUser(t *testing.T) {
4041 }
4142 }
4243
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+
4393 func TestVertexConfigRejectsBadType(t *testing.T) {
4494 tmp := t.TempDir()
4595 path := filepath.Join(tmp, "adc.json")
@@ -1,6 +1,7 @@
1 package provider1 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 {
3535 }
3636
3737 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
4445 }
4546
4647 // NewOpenAI creates an OpenAI client using an API key. baseURL may be empty.
@@ -431,8 +432,15 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
431432
432433 // ---- streaming ----
433434
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+
434442 func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
435- endpoint := chatCompletionsURL(c.baseURL)
443+ endpoint := c.chatCompletionsURL()
436444 wire, err := c.buildRequest(req)
437445 if err != nil {
438446 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 string38+ apiKey string
39- baseURL string39+ baseURL string
40- name string40+ chatCompletionsPath string
41- oauth bool // when true, apiKey actually holds an OAuth access token41+ name string
42- headers map[string]string42+ oauth bool // when true, apiKey actually holds an OAuth access token
43- http *http.Client43+ 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, err446 return nil, err
M packages/provider/openai_url_test.go +12 -0
@@ -38,3 +38,15 @@ func TestChatCompletionsURL(t *testing.T) {
3838 })
3939 }
4040 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close