detached 28b802b

Merge branch 'main' into gcp-global-endpoint

commit
28b802b159724c3bb59d063ed41aa324f8b987d9
author
Patric Eckhart · · 2026-07-12 14:19 UTC
committer
GitHub · · 2026-07-12 14:19 UTC
merge of
e7dc295 + cc6b04d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +827 -3
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 packages/agent/zotfile.go +132 -3
@@ -9,6 +9,8 @@ import (
99 "encoding/json"
1010 "fmt"
1111 "io"
12+ "net/http"
13+ "net/url"
1214 "os"
1315 "os/exec"
1416 "path/filepath"
@@ -75,12 +77,12 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
7577 return true, zotPack(dir, out)
7678 case "inspect":
7779 if len(rawArgs) < 2 {
78- return true, fmt.Errorf("zot inspect requires a .zot file or directory")
80+ return true, fmt.Errorf("zot inspect requires a .zot file, directory, or GitHub URL")
7981 }
8082 return true, zotInspect(rawArgs[1])
8183 case "verify":
8284 if len(rawArgs) < 2 {
83- return true, fmt.Errorf("zot verify requires a .zot file or directory")
85+ return true, fmt.Errorf("zot verify requires a .zot file, directory, or GitHub URL")
8486 }
8587 zf, cleanup, err := loadZotfile(rawArgs[1])
8688 if cleanup != nil {
@@ -93,7 +95,7 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
9395 return true, nil
9496 case "run":
9597 if len(rawArgs) < 2 {
96- return true, fmt.Errorf("zot run requires a .zot file or directory")
98+ return true, fmt.Errorf("zot run requires a .zot file, directory, or GitHub URL")
9799 }
98100 ref := rawArgs[1]
99101 rest := rawArgs[2:]
@@ -316,6 +318,9 @@ func zotPack(dir, out string) error {
316318 }
317319
318320 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+ }
319324 info, err := os.Stat(ref)
320325 if err != nil {
321326 return zotfileLoaded{}, nil, err
@@ -353,6 +358,130 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) {
353358 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
354359 }
355360
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+
356485 func readZotManifest(dir string) (ZotfileManifest, error) {
357486 var m ZotfileManifest
358487 b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
@@ -9,6 +9,8 @@ import (
9 "encoding/json"9 "encoding/json"
10 "fmt"10 "fmt"
11 "io"11 "io"
12+ "net/http"
13+ "net/url"
12 "os"14 "os"
13 "os/exec"15 "os/exec"
14 "path/filepath"16 "path/filepath"
@@ -75,12 +77,12 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
75 return true, zotPack(dir, out)77 return true, zotPack(dir, out)
76 case "inspect":78 case "inspect":
77 if len(rawArgs) < 2 {79 if len(rawArgs) < 2 {
78- return true, fmt.Errorf("zot inspect requires a .zot file or directory")80+ return true, fmt.Errorf("zot inspect requires a .zot file, directory, or GitHub URL")
79 }81 }
80 return true, zotInspect(rawArgs[1])82 return true, zotInspect(rawArgs[1])
81 case "verify":83 case "verify":
82 if len(rawArgs) < 2 {84 if len(rawArgs) < 2 {
83- return true, fmt.Errorf("zot verify requires a .zot file or directory")85+ return true, fmt.Errorf("zot verify requires a .zot file, directory, or GitHub URL")
84 }86 }
85 zf, cleanup, err := loadZotfile(rawArgs[1])87 zf, cleanup, err := loadZotfile(rawArgs[1])
86 if cleanup != nil {88 if cleanup != nil {
@@ -93,7 +95,7 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
93 return true, nil95 return true, nil
94 case "run":96 case "run":
95 if len(rawArgs) < 2 {97 if len(rawArgs) < 2 {
96- return true, fmt.Errorf("zot run requires a .zot file or directory")98+ return true, fmt.Errorf("zot run requires a .zot file, directory, or GitHub URL")
97 }99 }
98 ref := rawArgs[1]100 ref := rawArgs[1]
99 rest := rawArgs[2:]101 rest := rawArgs[2:]
@@ -316,6 +318,9 @@ func zotPack(dir, out string) error {
316 }318 }
317 319
318 func loadZotfile(ref string) (zotfileLoaded, func(), error) {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+ }
319 info, err := os.Stat(ref)324 info, err := os.Stat(ref)
320 if err != nil {325 if err != nil {
321 return zotfileLoaded{}, nil, err326 return zotfileLoaded{}, nil, err
@@ -353,6 +358,130 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) {
353 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil358 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
354 }359 }
355 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+
356 func readZotManifest(dir string) (ZotfileManifest, error) {485 func readZotManifest(dir string) (ZotfileManifest, error) {
357 var m ZotfileManifest486 var m ZotfileManifest
358 b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))487 b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
M packages/agent/zotfile_test.go +75 -0
@@ -3,6 +3,10 @@ package agent
33 import (
44 "archive/tar"
55 "bytes"
6+ "compress/gzip"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
610 "os"
711 "path/filepath"
812 "strings"
@@ -100,6 +104,77 @@ func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
100104 }
101105 }
102106
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+
103178 func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
104179 got := permissionSummary(tools.PermissionSet{})
105180 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
@@ -3,6 +3,10 @@ package agent
3 import (3 import (
4 "archive/tar"4 "archive/tar"
5 "bytes"5 "bytes"
6+ "compress/gzip"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
6 "os"10 "os"
7 "path/filepath"11 "path/filepath"
8 "strings"12 "strings"
@@ -100,6 +104,77 @@ func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
100 }104 }
101 }105 }
102 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+
103 func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {178 func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
104 got := permissionSummary(tools.PermissionSet{})179 got := permissionSummary(tools.PermissionSet{})
105 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {180 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {

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