Repository navigation
Tags: hashgraph-online/hol-guard
Tags
feat(plugin): check and convert plugins against the Agent Plugins spec ( #6021) * feat(plugin): check and convert plugins against the Agent Plugins spec `hol-guard plugin check --spec agent-plugins-1.0` reports whether a plugin root conforms to the Agent Plugins 1.0.0 spec: the closed plugin.json field set, skill names and descriptions, and each mcp.json server's transport, command, cwd, env and headers. Every finding names the spec section it breaks and whether a client would reject the plugin or only the component. `hol-guard plugin convert --to <runtime>` writes one runtime's package. The new agent-plugins target emits plugin.json and mcp.json with each server's type, rewrites runtime root placeholders to `${PLUGIN_ROOT}`, leaves out env, headers and servers that cannot be expressed portably (and says so), then checks the result against the spec. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * docs(plugin): document Agent Plugins check and convert Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(plugin): keep converted MCP servers runnable and report what clients skip Servers whose files the package does not carry, whose args read user variables, or that need a working folder or ${PLUGIN_DATA} are left out of converted configs and reported instead of shipping broken. Agent Plugins sources keep their plugin root in Claude Code and Gemini CLI. The check now rejects non-string or oversized skill descriptions, more skills than it can check, unreadable entries and malformed server URLs, and prints what clients do with each finding. The checker is split into smaller modules. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(plugins): carry only plain plugin files with converted servers A server that names the whole plugin root, reaches outside it with . or .. segments, goes through a symlinked folder, or names a file at the path of the generated plugin.json or mcp.json is now left out of the Agent Plugins package. A working folder inside the plugin travels with its server. Two different files at one package path stop packaging with plugin_kit_package_path_conflict, and a file reached twice is copied once before it counts against the size budget. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(plugin-kit): report a server whose working folder would ship empty A cwd folder that exists but holds no file the package walk keeps (empty, or every file excluded) no longer counts as bundled, so the server is left out of mcp.json and reported as not portable instead of pointing at a folder the package does not contain. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(plugin-kit): keep excluded and oversized server files out of Agent Plugins packages A file a server names now counts as bundled only when the package walk found it, so an excluded file makes the server not portable instead of shipping. A server file too large to copy fails the package instead of leaving the server in mcp.json without it. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * test(plugin-kit): cover an oversized server file inside a skill folder and clean up large fixtures Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
fix(release): ship a Linux ARM64 native wheel on the GitHub release (#… …6009) Linux ARM64 installs got only the universal wheel from PyPI, so Guard had no native runtime there and every protected command failed with native_unavailable. Releases now build a manylinux_2_17_aarch64 wheel around the static aarch64-unknown-linux-musl runtime on the ubuntu-24.04-arm runner and attach it to the GitHub release only, the same tier as Windows ARM64. PyPI staging leaves it out. The updater now treats manylinux_2_17_aarch64 as a published GitHub-only platform, so `hol-guard update` on a pure-wheel Linux ARM64 install fetches that wheel and checks it against the native-wheels.json digests embedded in the PyPI pure wheel, exactly as it does for Windows ARM64. Doctor and update payloads on Linux ARM64 now point to `hol-guard update` instead of reporting an unsupported platform. Pull request canaries skip the new runner, and repair runs of sources that predate this change keep their original wheel set. Fixes #5923 Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
fix(command): refuse a contained run that hands the shell a directory… … search path (#5996) * test(command): pin that a contained run is never handed a directory search path A bare relative `cd dashboard` is admitted into the workspace sandbox as the directory of that name below the run cwd. Elsewhere the decision will only read a relative operand that way against proof that the caller exported no CDPATH, so the containment reading looked like the loose one. It is not. The launcher builds the run environment from an allow-list, and CDPATH is on no list: it never reaches the sandbox, whichever way the caller spells it. A CDPATH the command sets itself is already refused in `cwd::locations`. Between them the search a shell would make cannot happen inside the sandbox, so the operand resolves to exactly the directory the shape names. That proof lived only in the gap between two files. Pin it with a test over `environment::filter`, and say in `cd::below` and `cwd::locations` which property the reading rests on, so a name added to the allow-list later has to answer for it rather than quietly undoing the containment shape. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(command): refuse a contained run that hands the shell a directory search path The containment shape reads a bare relative `cd dashboard` as the directory of that name below the run's cwd. The claim behind that reading was that CDPATH cannot reach the sandbox: the launcher builds the run environment from an allow-list and CDPATH is on no list. That covers an inherited CDPATH, not one the command carries. The launcher runs the command text as written, so `CDPATH=/ cd etc && ls` sets the search path inside the sandbox, the shell finds `/etc` before the workspace's own `etc`, and `ls` lists a directory the shape never read. Only `cwd::depths` noticed the name, and only to withhold a parent step, so a command that spends no `..` was admitted on the wrong directory. Screen it in the shape instead. `cd::sets_search_path` refuses a segment that sets CDPATH, in a leading assignment or in the arguments of a builtin that exports one, matched without case because zsh ties the same search to a lowercase `cdpath` and `export cdpath=...` there sets both. A command that only names the variable — greps for it, echoes it, reads a file called after it — sets nothing and stays contained. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(command): read every spelling that sets a directory search path The screen that refuses a contained `cd` when the command gives the shell its own `CDPATH` read only `export`, `declare`, `typeset`, `setenv` and `set`, and read a name up to its first `=`. `readonly CDPATH=/` and `local cdpath=/` set the variable as much as `export` does and are already carried as inert shell state, and `CDPATH+=/` appends, which from unset is the same as setting it. Either spelling put a search path in front of a bare relative `cd` operand that the shape then read against the workspace. The builtins that set a variable now include `readonly` and `local`, the name is read with a trailing `+` off, and the segment's own leading assignments are read as written, so an appending assignment ahead of the command is seen whether or not the parser carries it as an assignment. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
feat(runtime): derive and publish the policy verifier key natively (#… …6011) * feat(runtime): derive and publish the policy verifier key natively The resident refuses to serve until the derived verifier key exists, so provisioning that key can never be a resident call. This adds a one-shot `provision-verifier-key --stdin` arm to the runtime CLI, which takes the policy master on stdin — never on argv, where another local process could read it — derives the verifier key, and publishes it under the runtime's private state directory. Creation never replaces an existing key. On POSIX the publish is a `hard_link`, which fails with `AlreadyExists` rather than overwriting; on Windows it is a rename issued with `FILE_RENAME_REPLACE_IF_EXISTS` cleared, which reports the same. A key that is already there is read back and compared in constant time: a master that derives to something else is an explicit `native_policy_verifier_key_mismatch`, not a silent verifier reset. Nothing calls the new arm yet; the Python provisioning path still owns the callers and moves over separately. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(runtime): keep a failed verifier key write out of the final path Publishing the derived key hard-links a private temporary into place so it can never replace an existing key. Any link error other than AlreadyExists was read as a filesystem without links, which dropped a full disk, a denied directory or an I/O error into the fallback that creates the final path itself. If that write then failed, a short policy-verifier.key was left behind, and the error was answered with confirm_existing: callers saw native_policy_verifier_key_invalid or native_policy_verifier_key_missing rather than the write failure, and every later provisioning and the resident's own startup check rejected the stub until someone deleted it. Fall back only for a link the filesystem refuses outright (Unsupported, EPERM, EOPNOTSUPP, ENOSYS) and report every other link error. In the fallback, track whether this call created the file: a write that fails after our own exclusive create removes it again, a create that fails with a key already there is still the race that confirm_existing settles, and a create that fails with nothing there returns the write failure. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * test(runtime): give each verifier key test its own temporary directory A process id and a counter both repeat across runs, so a directory left behind by a failed assertion could be read as a later run's state and turn a Created into a Verified or a key mismatch. TempDir names a directory nothing else can reach and removes it however the test ends. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
fix(connect): keep polling for device approval when one request goes … …unanswered (#6005) * fix(connect): keep polling for device approval when one request goes unanswered A single token poll that timed out or hit a reset connection ended `hol-guard connect --headless` with "Guard authorization failed: timed out", even though the device code was still valid and the user could still approve it. RFC 8628 section 3.5 asks clients to back off and keep polling after a connection timeout. Unanswered polls now double the interval (capped at 60 seconds) and keep waiting until the device code deadline. Non-transient URL errors such as certificate failures, and managed-network policy blocks, still fail at once. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(connect): never poll for device approval after the wait window closes Cap the backoff sleep after an unanswered poll at the remaining wait window and check the deadline again before the next request, so a long backoff cannot start a late poll. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(connect): keep the server polling interval when backing off A device-code interval above the 60-second backoff cap was lowered to 60 after an unanswered poll, so the next request came sooner than Guard Cloud asked. The backoff now never drops below the current interval. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
feat(guard): check project agent hooks in background workspace assess… …ments (#6014) * feat(guard): check project agent hooks in background workspace assessments Workspace assessments now read the hook configuration files agents load from the worktree root and run each hook command through the shared launch screen. A hook that pipes a download into a shell, sends the environment to the network or runs under sudo is listed as a dashboard warning with its event and configuration path, never the command. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(guard): read top-level hook events only where agents allow them and bound arguments Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(guard): name every cause of an incomplete hook check in the dashboard Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
fix(extensions): stop a failed policy save from blocking every later … …save (#6015) * fix(extensions): stop a failed policy save from blocking every later save A save that failed after writing its prepared transition, but before the vault anchor moved, left that row in place. Every later read reported the authority as needing recovery, preview refused with authority_unavailable before apply's own recovery could run, and the daemon only ever read under a shared lease, which cannot roll anything back. Nothing cleared it, so the operator stayed locked out of extension policy and extensions resolved blocked. - The failing save now takes its own prepared row back at once. The resident abandons a row the caller names by its transition digest without waiting out the retry window, and still keeps it if the anchor moved. - Preview reads once more as the writer before refusing, so a row a crashed save left behind is rolled back on the next attempt. - The daemon's periodic refresh reads as the writer when its shared read reports recovery required, so an affected install heals on its own. - A protected read now replaces a non-protected snapshot at the same revision, and apply refusals are logged by exception type. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * test(extensions): model crashed saves where a test needs a prepared row A save that fails in process now takes its own prepared row back, so the tests that need a row left behind simulate a process that died after preparing. The self-heal preview test stops at target metadata instead of depending on pack preparation. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * docs(runtime): note that a failed save names its own row for rollback Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
fix(command): walk a project's own skills tree without review (#5994) * fix(command): walk a project's own skills tree without review A checkout ships `.agents/skills`, `.claude/skills` or `.codex/skills` beside its code, and an agent finds its skills by walking that tree. A plain listing of the session folder already prints those names, so `ls .agents` was admitted while `find .agents -maxdepth 3 -type f -print` asked for approval — the walk that reads the project's own skills had to stop on an approval row. Extend `skill_listing_root`, the names-only listing root every one of those callers shares, with the tree a checkout carries itself. It reuses the directory rules `project_skill_listing_directory` already carries (the `.agents`/`.claude`/`.codex` marker, a `skills` child, no hidden component below it, and an `.agents` root that holds nothing but `skills`) and adds the credential-store screen the home tree keeps, so `find`, `ls` and the read-argument screen answer the same way. Each document below the tree still keeps its own read proof, and a hidden project directory that is not a skills tree still asks. Regression cases: `find-project-agents-root`, `find-project-skills-tree` and `find-project-agents-root-then-listing` in `tests/fixtures/benign_false_positive_gate.json`, plus `rust/crates/guard-command/tests/pretool_find_project_skill_root.rs` for the negatives the gate's fixture home cannot build. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * test(command): drop the hidden-directory negative the exact-safe route already answers `find .worktrees -maxdepth 2 -type f -print` was asserted to keep review, but a names-only walk of an inert hidden directory is already admitted by the exact-safe command route, with or without the project skills tree. The assertion said nothing about the new admission, which is bounded by `project_skill_listing_directory` and the credential-store screen that the remaining two negatives cover. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(command): screen what a descending skill listing reaches, not only its root `skill_listing_root` screened the operand: a hidden folder or a credential store named as the root is refused, and the home tree additionally refuses a hidden component below `skills`. `find` was given that same screen and then walked. So `find .agents/skills -type f` printed `.agents/skills/.private` and `.agents/skills/credentials`, the two names the screen refuses when either is named as the root, and the tree the operand rules declined to list was listed from one level up. A listing that descends now has to clear the bounded metadata walk as well, which is what `ls -R` of the `.agent` root has always had to do. `find` asks for it on every root, because it prints a hidden entry at any depth its expression reaches. `ls` without `-R` prints no hidden name and descends nowhere, so its route is unchanged and a plain listing of the same tree stays silent. The misplaced doc comment above `skill_link_resolves_over_home`, which described `skill_listing_root`, now sits on the function it describes. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(command): keep the home skill listing the home trees already had The descending screen was applied to every skill root, including the two home trees, where it refuses a hidden descendant on its name alone. A skill directory holding a `.cache` or a `.venv` is the ordinary case there, so `find ~/.agents/skills/review-loop -type f` started asking — a listing that has been silent, and that `pretool_skill_listings` pins. Apply the walk where this change opened the listing: the tree a checkout carries itself. The hidden folder a home tree reaches is a gap that predates this change, and closing it needs a screen that reads the names it finds rather than one that refuses every hidden entry. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
fix(claude): store a tool-call permission notice with its credentials… … masked (#6001) * fix(claude): store a tool-call permission notice with its credentials masked A Claude Code review writes a signed permission notice and a pending row holding the command that asked. Both kept the raw text, so a bearer token or API key in a curl header sat in `sync_state` in plain text. The notice's command only ties the notice to its pending row and is never an approval binding (that rides on `artifact_hash`), so the store now masks `command` and `raw_command_text` on `tool_call` notices before signing, the same way the review queue stores the command. Both the direct hook lane and the daemon's fallback writer go through this one store step. Other artifact types keep their command, since a `tool_action_request` command feeds the saved policy's exact-match context. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * test(runtime): build the masked bearer fixture at runtime The credential-masking test spelled a synthetic bearer token inside a curl command literal, which the curl-auth-header rule flags. Build the command from a separate token binding and record the earlier commit's fingerprint in .gitleaksignore. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(claude): mask credentials passed as command arguments in tool-call notices `redact_text` masks an assignment only at the start of a line, so a notice for `curl --api-key=... url` or `tool --password ... run` still stored the value in both signed rows. `redact_command_text` adds an argument pass: a value whose name carries a sensitive fragment is masked (`--api-key=*****`, `'X-Api-Key: *****'`, and the argument after a bare `--password`), quoted values are masked to their closing quote, and shell operators are never masked. The notice and its pending row use it, so they still agree when the prompt is marked seen. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(claude): mask a credential argument from its first sensitive separator A header value ending in base64 padding, such as `X-Api-Key: <value>=`, put the padding `=` first in the search, so the whole value was read as the name and left in place. The value now starts at the first `=` or `:` whose name is sensitive, which also keeps `--header=X-Auth-Token:<value>` masked. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
fix(native): read logged launches, rooted find -exec and looped inlin… …e programs as review (#6007) * fix(native): read logged launches, rooted find -exec and looped inline programs Three shell shapes from #5931 failed native evaluation and were blocked: a background launch logged to a file (`node server.js > out.log 2>&1 &`), a `find` rooted at a quoted parameter with `-exec basename {} \;` piped to `sort` and followed by `shasum`, and a `for` loop that runs `node -e` on each matched file. They now reach review. - A redirected launch is read when every redirect names a literal target, nothing reads input, and the text without redirects models exactly. - `find -exec` accepts `$NAME/` or `${NAME}/` inside double quotes as a root path. - Loop bodies are split outside quotes; each inline interpreter launch must name a single program source, and a loop that changes directory or uses a computed command word keeps the floor. - Assigning a plain lowercase shell variable is no longer read as moving data. Commands where a part could run something else (shell reading a file, unnamed redirect targets, preloads, `sh -c` under a parameter root) keep the evaluation-failed floor. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(native): keep the floor for written scripts, prefixed cd and expanded values - A redirect that writes a file the rest of the text names is no longer read as a log, so `printf '...' > run.js && node run.js` keeps the evaluation failure. - The directory-change check reads past `builtin`, `command`, `time`, `nohup` and `exec`, and refuses `eval`, `source` and quoted or computed command words. - A plain shell variable may expand a parameter only as the quoted root of a path, so `x=$SECRET` still counts as moving data. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(native): count globbed and partly quoted names of a written file The write-then-run check compared names spelled exactly, so a launch of the file a redirect just wrote through `r*.js`, `ru"n.js"`, `r\un.js` or `"$f"` still read as a log. Names now compare with quotes and escapes removed, and a word that could expand to the name (an unquoted glob or brace, or a parameter outside single quotes other than `$!`) counts as naming it. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(native): match a written module by the names an interpreter resolves `node run` loads `run.js`, and `node ./lib` or `node .` load an `index.js`, so a redirect that writes one of those files and a launch that names it that way still ran a program the scan never read. A module-like target now matches its stem, any directory on its path, and `.` or `..`. Plain output such as `/tmp/x.log` keeps exact-name matching, so `npm run dev > /tmp/dev.log &` is not caught by its stem. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(native): read directory launches and operator-joined redirect targets `node ./` and `node lib/` load an `index.js` but left an empty name, so a redirect that wrote that file still read as a log. A word now names its last non-empty component, and one with none left names the directory as `.` does. A redirect target also ends at the first shell operator, and a process-substitution target counts as named. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(native): find redirects glued to a quoted word A redirect written straight after a closing quote, as in printf 'x'>run.js, is now read as a write to run.js, so a following launch of that file keeps its evaluation failure. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> * fix(native): list the compound launch module and accept the containment floor The command source manifest now names native_command_unmodeled_compound.rs. A written script launched in the foreground meets workspace containment first where a sandbox exists, which is stricter than review, so the negative controls accept that floor as well as the evaluation failure. Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com> --------- Signed-off-by: ZCF <291624129+zerocodefast@users.noreply.github.com>
PreviousNext