SF-015: Malformed GIT_CONFIG_COUNT environment variable causes unhandled ValueError in _build_github_auth_env
- Severity: Low
- Location:
agr/git.py:_build_github_auth_env - Description:
_build_github_auth_env()reads theGIT_CONFIG_COUNTenvironment variable and parses it withint(os.environ.get("GIT_CONFIG_COUNT", "0")). IfGIT_CONFIG_COUNTis set to a non-numeric string (e.g. by a CI step that misconfigures the environment, or by another tool), this raises an uncaughtValueError. The exception is not caught by_run_git()(which only catchesOSError) nor by any upstream caller, so it surfaces to users as a raw Python traceback rather than a friendlyAgrError. In CI environments with imperfect variable management this causes a confusing, hard-to-diagnose failure. Additionally, an excessively large numeric value (e.g.GIT_CONFIG_COUNT=999999999) would produce valid env var names likeGIT_CONFIG_KEY_999999999, causing git to scan for 10⁹ config entries at startup — a minor DoS on the git subprocess. Git itself silently ignores a malformedGIT_CONFIG_COUNT; agr should do the same. - Resolution: Wrapped the
int()call in atry/except ValueErrorthat falls back to0, matching git's own behaviour. Added 1 test:test_ignores_malformed_git_config_count— verifies that a non-integerGIT_CONFIG_COUNT("notanumber") is ignored and the auth env is still populated correctly at index 0. - Status: Fixed (2026-04-17)
SF-014: git sparse-checkout set missing -- separator allows option injection from repo-supplied paths
- Severity: Low
- Location:
agr/git.py:checkout_sparse_paths - Description:
checkout_sparse_pathsbuilt thegit sparse-checkout setcommand by appending path arguments (derived fromgit ls-treeoutput of the remote repository) without inserting a--end-of-options separator. Without--, git parses every subsequent argument for option flags; a repository that contains a file or directory whose path starts with-(e.g.,-C,--cone,--no-cone) would cause git to interpret that path as a command-line option rather than a sparse-checkout pattern. A malicious remote repository could craft such a path to alter git's sparse-checkout behaviour — for example,--no-conewould silently switch sparse-checkout from cone mode to non-cone mode, potentially causing all files in the repo to be checked out instead of only the requested skill directory, expanding the attack surface for subsequent install steps (including symlink-following, SF-001 mitigated separately). While GitHub does not allow filenames beginning with-in repository paths, git hosting services that do permit them, or on-premise gitea/Gitea instances used as custom sources, could serve such paths. The impact is bounded by what git option names are valid forsparse-checkout set, but the absence of--is an unsafe pattern that violates the principle of treating data separately from commands. - Resolution: Fixed. Added
"--"as the argument immediately after"set"in the_git_cmdcall insidecheckout_sparse_paths, so the final command isgit -C <repo> sparse-checkout set -- <paths...>. This ensures all subsequent arguments are treated as paths regardless of their content. Added 4 tests: verifying--is present and immediately followsset; paths appear after--; a--prefixed path is placed after--rather than being parsed as an option; and the empty-paths guard still raisesAgrError. - Status: Fixed (2026-04-17)
- Severity: Low
- Location:
agr/sdk/hub.py:list_skills - Description:
list_skills(repo_handle)split the repo handle on/and used the resulting components directly in the GitHub API URL (_github_tree_url) without passing them through_validate_component. This bypassed all the security checks introduced by SF-003, SF-008, SF-009, SF-010, SF-011, and SF-012: control characters, whitespace, YAML flow/indicator/block-scalar characters, path-traversal components (..,.), and the reserved--separator were all accepted. In contrast,skill_info()— the other public SDK function — correctly calledparse_remote_handle()which routes through_validate_component. The impact in the SDK context is limited because (1) the base URL is hardcoded tohttps://api.github.com, preventing host-based SSRF, and (2) httpx rejects URLs with embedded control characters at the HTTP layer. However, crafted inputs with#(URL fragment delimiter) could silently redirect the API request to a different GitHub API endpoint — e.g.,list_skills("owner#evil/repo")would requesthttps://api.github.com/repos/owner(fragment truncates the path) instead of raising an error. The inconsistency also means developer code relying onlist_skillsfor validated input identity (e.g., caching by handle) could be surprised by inputs thatskill_infowould reject. - Resolution: Fixed. Replaced the raw
split("/")+ direct-use pattern inlist_skillswith calls toparse_remote_handleusing a dummy skill-name suffix (/placeholder) so that_validate_componentruns on the owner and repo components before they are interpolated into any URL. For the 1-part case ("owner") the call isparse_remote_handle("owner/placeholder"); for the 2-part case ("owner/repo") it isparse_remote_handle("owner/repo/placeholder"). The validatedusernameandrepofields from the resultingParsedHandleare then used instead of the raw split parts. Added 10 tests covering control chars, whitespace, YAML special chars, path traversal in owner and repo positions, and positive regression tests confirming valid handles still proceed to the (mocked) network call. - Status: Fixed (2026-04-17)
SF-012: YAML block-scalar and quoted-scalar characters in handle components corrupt SKILL.md frontmatter
- Severity: Low
- Location:
agr/handle.py:_validate_component,agr/skill.py:update_skill_md_name - Description:
_validate_componentblocked whitespace/control chars (SF-008), YAML flow-collection delimiters{}[](SF-010), and YAML indicator characters#*&!(SF-011), but did not block YAML block-scalar indicators|and>or quoted-scalar start characters'and". When these appear in a skill directory name,update_skill_md_namewrites them directly into the SKILL.md frontmatter asname: <value>. In YAML block style, these characters corrupt thenamefield: (1)|skill— starts a block literal scalar withskillas the block header; becausesis not a valid chomping/indentation indicator, strict YAML parsers raise a parse error; (2)>skill— same mechanism for block folded scalars; (3)'skill— opens a single-quoted scalar that is never closed, causing the YAML parser to consume all subsequent frontmatter lines (includingdescription:) as part of the name value, suppressing those fields from agents; (4)"skill— same swallowing mechanism for double-quoted scalars. The'and"cases are particularly impactful because they do not cause an immediate parse error — instead they silently hide thedescriptionand other frontmatter fields that agents rely on for skill discovery. The attack is most effective through transitive package dependencies where the user never reviews the raw handle. None of these characters are valid in GitHub usernames or repository names; they can appear in skill directory names in a Linux-hosted git repository. - Resolution: Fixed. Added
_YAML_BLOCK_SCALAR_CHARS = frozenset("|>'\\"")constant and_validate_no_yaml_block_scalar_chars()helper inhandle.py. Wired into_validate_componentafter the existing indicator-char check, so all remote handle parsing branches apply the new check. Added 7 tests covering|and>in skill name, username, and repo positions;'and"in skill name; and|in the one-part default_owner path. - Status: Fixed (2026-04-17)
- Severity: High
- Location:
agr/_install_common.py:copy_resource_to_destination,agr/sdk/cache.py:cache_skill - Description:
shutil.copytree()was called with the defaultsymlinks=False, which dereferences symlinks and copies the content of the files they point to. A malicious remote skill repository could include symlinks targeting sensitive local files (e.g.~/.ssh/id_rsa,.env,~/.aws/credentials). When a user installs such a skill viaagr addoragrx, the symlinks are dereferenced during the copy, placing the actual file contents into the installed skill directory. Since skills are read by AI agents as context, the sensitive content could then be exposed in agent responses. - Resolution: Fixed. Added
_ignore_symlinkshelper that filters out symlink entries duringshutil.copytree. Applied to bothcopy_resource_to_destination(main install path) andcache_skill(SDK cache path). Added tests confirming that file symlinks and directory symlinks are both skipped during installation. - Status: Fixed (2026-04-12)
- Severity: Medium
- Location:
agr/source.py:SourceConfig.build_repo_url,agr/config.py:_parse_source_entry,agr/commands/config_cmd.py - Description:
SourceConfig.build_repo_url()used Python'sstr.format()to interpolate{owner}and{repo}into the source URL template. Becausestr.format()supports dotted attribute access (e.g.{owner.__class__.__mro__}), a maliciousagr.tomlin a cloned repository could craft a[[source]]URL template that leaks Python runtime internals. When a user runsagr syncoragr addagainst such a project, the format string is evaluated and the resolved attributes are embedded in the git clone URL — potentially exfiltrating runtime metadata to an attacker-controlled server. Additionally, source URLs accepted arbitrary schemes (e.g.ftp://,data:), which could be abused for protocol-level attacks. - Resolution: Fixed. Replaced
str.format()with literalstr.replace("{owner}", owner).replace("{repo}", repo)inbuild_repo_url(), which prevents attribute access, index access, and conversion specifiers. Added URL scheme validation in both_parse_source_entry()(TOML loading) and theconfig add sourcesCLI command, restricting URLs tohttps://,http://,ssh://,git://,file://, and absolute paths. Added tests confirming that format string attribute access and conversion specifiers are not evaluated, and that unsupported URL schemes are rejected. - Status: Fixed (2026-04-12)
- Severity: High
- Location:
agr/handle.py:parse_handle - Description: Remote handle components (username, repo, skill name) were validated against the reserved
--separator but not against path traversal sequences.and... A handle likeuser/repo/..would parse successfully withname="..", causing the install destination to resolve toskills_dir / ".."— the parent of the skills directory. In the worst case (agr sync --forceoragr upgrade),shutil.rmtree()would delete the parent directory beforeshutil.copytree()replaces it with skill contents. This is especially dangerous through the package expansion path (package.py:expand_packages): a malicious remote package could include a sub-dependency with a traversal handle in itsagr.toml, and the user would never see the raw handle — they'd only have a top-leveltype = "package"dependency. The same issue applied to.as a component, which would resolve the destination to the skills directory itself. Local handles were already protected (.produces an empty name caught bynot name,..is explicitly rejected), but the remote handle branch had no such checks. - Resolution: Fixed. Added
_validate_no_path_traversal()helper inhandle.pythat rejects.and..as handle components. Applied to all remote handle parsing branches (1-part with default_owner, 2-part user/skill, 3-part user/repo/skill) alongside the existing_validate_no_separator()check. Added 8 tests covering traversal in each component position and both.and..variants. - Status: Fixed (2026-04-12)
- Severity: Medium
- Location:
agr/git.py:fetch_and_checkout_commit,agr/lockfile.py:LockedEntry.from_dict - Description: The lockfile's
commitfield was loaded from disk as an arbitrary string with no format validation. In--frozenmode,fetch_and_checkout_commit()passed this string directly togit fetch --depth=1 origin <commit>andgit checkout <commit>. While agr always writes valid 40-character hex SHAs when generating the lockfile, a tampered lockfile (e.g. via a malicious pull request that modifiesagr.lock) could substitute a branch name (main), tag (v1.0), or other git ref for a pinned commit SHA. When another developer then runsagr sync --frozen, git would resolve the ref to whatever commit it points to at that moment — potentially a different (malicious) commit than the one originally pinned. This defeats the core purpose of--frozenmode: ensuring reproducible, immutable installs from exact commit SHAs. The attack is particularly effective because lockfile diffs are often large and reviewers tend to skim them. - Resolution: Fixed. Added
validate_commit_sha()function ingit.pywith a regex check (^[0-9a-f]{40}$) that rejects anything other than a full 40-character lowercase hex SHA. Called at the top offetch_and_checkout_commit()before any git operations. Added 11 tests covering valid SHAs, branch names, tags, short SHAs, uppercase hex, empty strings, oversized strings, and ref paths. - Status: Fixed (2026-04-12)
- Severity: Medium
- Location:
agr/git.py:_apply_github_token,agr/git.py:downloaded_repo - Description:
_apply_github_token()embedded the GitHub token directly into the git clone URL (e.g.https://TOKEN:x-oauth-basic@github.com/owner/repo.git). This URL was passed as a command-line argument togit cloneandgit ls-remoteviasubprocess.run(). On Linux, command-line arguments are world-readable via/proc/PID/cmdline; on macOS, they are visible to all users viaps aux. Any user or process on the same machine could observe the token during the (brief) git operation window. This is especially concerning on shared CI/CD runners, multi-user development servers, and environments with process monitoring. Although the token exposure window is short (the lifetime of the git subprocess), automated process scanners or audit logging could capture it persistently. - Resolution: Fixed. Added
_build_github_auth_env()function that passes the GitHub token viaGIT_CONFIG_COUNT/GIT_CONFIG_KEY_N/GIT_CONFIG_VALUE_Nenvironment variables (git 2.31+). These env vars configurehttp.https://github.com/.extraheaderwith anAUTHORIZATION: bearerheader, scoped to github.com only. The_run_git()function now automatically merges the auth env into every git subprocess call. Environment variables are only readable by the owning user (unlike cmdline which is world-readable), and the URL-scopedhttp.extraheaderensures the token is never sent to non-GitHub hosts. The function also correctly appends to any existingGIT_CONFIG_COUNTentries. Added 7 tests covering token presence in env values, absence from keys, env count appending, GH_TOKEN fallback, empty token handling, and github.com scoping. - Status: Fixed (2026-04-12)
- Severity: High
- Location:
agr/package.py:expand_packages - Description:
expand_packages()rejected local packages (type = "package"with apathfield) but silently accepted local skill/ralph sub-dependencies from remote packages. A malicious remote package could include{path = "../../.env", type = "skill"}in itsagr.toml. Duringagr syncoragr add, the package expansion would add this local path dependency to the flat list. When later installed, the path resolves relative to the user'srepo_root, causing the contents of arbitrary directories to be copied into the skills directory where AI agents read them. The attack is especially effective because package sub-dependencies are expanded transparently — the user only sees a top-leveltype = "package"entry in theiragr.tomland never reviews the transitive local paths. A nested package chain (package A depends on package B which contains the malicious local dep) further obscures the attack. - Resolution: Fixed. Added a check at the top of the sub-dependency loop in
expand_packages()that rejects any local path dependency (sub_dep.is_local) found in a remote package's sub-manifest, raisingConfigErrorwith a message naming both the offending path and the parent package. This applies to all dependency types (skills, ralphs, and nested packages). Added 5 tests covering: local skill paths, local ralph paths, traversal paths, nested package chains with local deps, and error message content verification. - Status: Fixed (2026-04-12)
- Severity: Medium
- Location:
agr/lockfile.py:LockedEntry.from_dict,agr/commands/sync.py:_sync_from_lockfile - Description: SF-006 prevents local paths from appearing as transitive dependencies at package-expansion time:
expand_packages()converts any local sub-dep in a remote package'sagr.tomlto a same-repo remote handle via_remote_dep_from_repo_path. However,--frozen/--lockedsync skips package expansion entirely and trusts the lockfile.LockedEntry.from_dictaccepted any combination of fields, including an entry with both apathand aparent(package parent)._locked_transitive_deps_for_packagesthen returned the tampered entry,_dep_from_locked_entrywrapped it into aDependency(path=..., type="skill"), and_sync_dep_from_lockfileinstalled it as a local dep —handle.resolve_local_path(repo_root)resolves the attacker-chosen path (absolute or with..traversal) andinstall_local_skillcopies its contents into the skills directory where AI agents read them. The attacker path (e.g../.cache/agr/skills/..., the user's ownskills/foo/, or any directory containing aSKILL.md) only needs to contain a validSKILL.mdfor the install to proceed. Because a legitimateagr syncnever producespath + parententries (SF-006 converts them to remote before lockfile write), this combination is only reachable via lockfile tampering — exactly the threat model SF-004 addressed for commit SHAs. The attack is effective in PR-review scenarios where lockfile diffs tend to be skimmed. - Resolution: Fixed. Added a validation block in
LockedEntry.from_dictthat raisesConfigErrorwhenever an entry has bothpathand aparent/parentsvalue, with a message indicating possible corruption or tampering. This stops the tampered entry at lockfile-load time so every downstream code path (including--frozensync) is protected. Added 4 tests covering: rejection with scalarparent, rejection withparentslist, that direct local entries (no parent) still load, and that remote transitive entries (handle + parent) still load. - Status: Fixed (2026-04-17)
SF-008: Handle components accept whitespace and control chars, enabling SKILL.md frontmatter injection
- Severity: Medium
- Location:
agr/handle.py:_validate_component,agr/skill.py:update_skill_md_name - Description: Remote handle components (username, repo, skill name) were validated against the reserved
--separator and against./..path traversal, but not against whitespace or ASCII control characters.update_skill_md_name— invoked as thepost_copycallback incopy_resource_to_destination— writes the handle's skill name into the installedSKILL.md's YAML frontmatter asname: {new_name}. When the name contains a newline (e.g.skill\ndescription: injected-prompt\n), the newline closes thename:line and the attacker-supplied YAML keys are appended verbatim to the frontmatter. This gives a malicious remote package a covert channel to inject a forgeddescription:(or other frontmatter fields) that agents may rely on when picking / summarising skills — the user only sees a top-leveltype = "package"dep and never reviews the transitive handle string. The same names also end up as literal directory names on disk (containing newlines) and as theinstalled_namefield in.agr.json. Legitimate GitHub usernames, repo names, and skill names never contain whitespace or control characters, so rejecting them is safe. - Resolution: Fixed. Added
_validate_no_control_chars()helper inhandle.pythat rejects any component containing a whitespace character (str.isspace(), which includes space, tab, newline, carriage return, vertical tab, form feed) or an ASCII control byte (ord < 0x20or0x7F). Wired into_validate_componentalongside the existing separator and traversal checks, so every remote parsing branch (1-part with default_owner, 2-part user/skill, 3-part user/repo/skill) now applies the check. Local handles continue to use_validate_no_separatoronly — the frontmatter-injection attack is blocked for remote/transitive handles (the actual threat vector). Added 10 tests covering newline / tab / space / carriage return / null byte / DEL characters in each component position plus a positive test that legitimate hyphenated names still parse. - Status: Fixed (2026-04-17)
- Severity: Low
- Location:
agr/handle.py:_validate_component,agr/skill.py:update_skill_md_name - Description:
_validate_componentrejected--,./.., whitespace, and control characters, but allowed YAML flow-collection delimiters:{,},[,]. A git repository can contain a skill directory named with these characters (e.g.,{skill}or[item]). When agr installs such a skill,update_skill_md_namewrites the handle's skill name directly into the installed SKILL.md frontmatter asname: {skill}orname: [item]. In YAML,{skill}is an invalid flow mapping (theskillkey has no value, causing a parse error in strict parsers), while[item]is a valid flow sequence — meaning thenamefield's type becomes a list instead of a string. This corrupts the frontmatter in a way that can: (1) cause YAML parse errors in strict agents, (2) produceTypeErrorcrashes in agents that assumenameis always a string, and (3) mislead skill-routing logic that matches against thenamefield. While these characters cannot inject new top-level YAML keys (line breaks required — blocked by SF-008) or enable YAML deserialization exploits (complex payloads require spaces — also blocked by SF-008), they do corrupt the existingnamefield's type. The attack is particularly effective through transitive package dependencies: a malicious remote package can include a sub-dependency whose directory name contains{or[, and the user never sees the raw handle. Legitimate GitHub usernames, repository names, and skill directory names never contain these characters, so rejecting them is safe. - Resolution: Fixed. Added
_validate_no_yaml_flow_chars()helper inhandle.pythat rejects any component containing{,},[, or]. Wired into_validate_componentalongside the existing checks, so all remote handle parsing branches apply the new check. Added 9 tests covering brace/bracket characters in each component position (username, repo, skill name, one-part with default_owner) and a positive test confirming underscore/hyphen names still parse. - Status: Fixed (2026-04-17)
- Severity: Low
- Location:
agr/config.py:_validate_config_identifier - Description:
_validate_config_identifiervalidateddefault_owneranddefault_repofromagr.tomlagainst/and the reserved--separator, but not against internal whitespace, ASCII control characters, or the literal./..path-traversal values. Trailing/leading whitespace was stripped, but characters embedded in the middle of the value were preserved verbatim. Whiledefault_ownerhas a backstop inparse_handle._validate_component(called when 1-part handles are resolved),default_repoflows directly into the git clone URL viaiter_repo_candidates→handle.get_github_repo→source.build_repo_url(owner, repo)without ever passing through_validate_component. A malicious project'sagr.tomlwithdefault_repo = "skills\nHost: evil.com"(or similar control-char-bearing value) would produce a git clone URL containing newlines, which (depending on the libcurl version git was built against) could either be rejected with a confusing error, URL-encoded, or — in older curl builds without strict CTL filtering — be transmitted as part of the HTTP request line. Settingdefault_repo = ".."produces URLs likegithub.com/owner/...gitwhich curl/GitHub then normalize away. The realistic exploit potential is low because (a) the handle'susernamealways comes from the explicit handle (notdefault_repo), so the URL host/owner can't be redirected, (b) the existing/rejection blocks the dangerous URL-path-traversal redirect attack, and (c) modern curl rejects URLs with embedded control characters. Still, validating at the application layer matches the defense-in-depth pattern established by SF-008 and gives a clearer error at config load time instead of an opaque curl/git failure later. - Resolution: Fixed. Extended
_validate_config_identifierwith two additional checks applied after the existing strip/empty///--validation: (1) per-character iteration that rejects any whitespace character (str.isspace()— includes space, tab, newline, CR, vertical tab, form feed) or ASCII control byte (ord < 0x20or0x7F); (2) literal-value rejection of.and..to block path-traversal-like segments in the constructed git URL. Internal dots in repo names (e.g.website.github.io) remain valid because GitHub itself allows them. Added 9 tests intest_config.pycovering newline / internal space / tab indefault_repoanddefault_owner, the./..rejection for both keys, plus a positive test confirmingdefault_repo = "foo.bar"still loads. - Status: Fixed (2026-04-17)
- Severity: Low
- Location:
agr/handle.py:_validate_component,agr/skill.py:update_skill_md_name - Description:
_validate_componentblocked whitespace/control chars (SF-008) and YAML flow-collection delimiters{}[](SF-010), but did not block YAML indicator characters#,*,&, and!. When these appear in a skill directory name,update_skill_md_namewrites them directly into the SKILL.md frontmatter asname: <value>. In YAML block style, these characters alter value semantics: (1)#preceded by whitespace is a comment indicator —name: #foosetsnameto null, silently discarding the skill name; (2)*is an alias indicator —name: *anchortriggers an alias dereference; if the anchor is undefined (the normal case), most YAML parsers raise a parse error; (3)&is an anchor indicator —name: &anchorsets an anchor on a null scalar, sonamebecomes null; (4)!is a tag indicator —name: !!nullorname: !tag valchanges the inferred type, potentially producing a non-stringnamefield. The most impactful case is#, which silently nulls the name without any parse error, confusing agent skill-routing logic that relies on thenamestring. None of these characters are valid in GitHub usernames, repository names, or conventional skill directory names, so rejecting them is safe and has no user-visible impact on legitimate handles. The attack is most effective through transitive package dependencies where the user never sees the raw handle. - Resolution: Fixed. Added
_validate_no_yaml_indicator_chars()helper inhandle.pythat rejects any component containing#,*,&, or!. Wired into_validate_componentafter the existing flow-char check, so all remote handle parsing branches (1-part with default_owner, 2-part user/skill, 3-part user/repo/skill) apply the new check. Added 7 tests covering#in skill name, username, and repo positions;*,&, and!in skill name; and#in the one-part default_owner path. - Status: Fixed (2026-04-17)