fix(manifest): reject namespace strings that are not safe path segments - #710
Conversation
|
Findings
|
…is wrong Review findings on Tencent#710. The first cut reused SAFE_ID (^[A-Za-z0-9._-]+$) for resource namespaces. That allowlist is right for a project id, which is also typed on the command line and split on commas, but for a namespace it rejects far more than traversal: a team whose skills live under a non-ASCII directory, or one with a space in the name, would have stopped parsing although the directory is perfectly safe. The namespace guard now tests what actually matters -- no path separator, no `:` (drive-relative on Windows), no control character, and not `.` or `..` -- while the project id keeps its narrower spelling. Both live in src/manifest-schema.ts, which is what roles.yaml and projects.yaml genuinely share; roles.ts no longer reaches into projects.ts for the schema. Running the CLI against a manifest with `../../evil` showed the second half: the zod failure escaped as a raw ZodError, so `teamai pull` printed a validation object instead of a sentence. parseManifest now reports it the way the hand-written checks beside it do, naming the entry: Invalid projects manifest: projects.0.resources.skills.1: resource namespace must be a single path segment (no '/', '\', ':' or control characters, and not '.' or '..') Docs: the namespace rule is stated where each manifest is documented, in both usage guides and in the multi-project design doc.
ebe0af5 to
2048a89
Compare
|
All three findings are addressed, and the branch is rebased onto current P1 — the ASCII allowlist rejected more than traversalRight, and the PR body was wrong about it. The namespace guard now tests what actually matters and nothing else: - const SAFE_ID = /^[A-Za-z0-9._-]+$/; // allowlist, applied to both
+ const UNSAFE_SEGMENT = /[/\\:\u0000-\u001f]/; // namespace: separators, ':', control chars
+ seg !== '.' && seg !== '..'
+ const SAFE_ID = /^[A-Za-z0-9._-]+$/; // project id only, unchanged
Both rules now live in Found while running the matrix: the error was unreadableThe Test Plan in the PR body claimed a tidy P1 — bilingual documentationThe namespace rule is now stated wherever either manifest is documented, in both languages:
P1 — real-CLI matrixThrowaway fixture team repo, three namespaces (
Delivered per cell, from all three namespaces including the non-ASCII one:
Provider-specific paths (
Rejection path, pushed to the fixture remote so the clone really receives it:
One observation unrelated to this change: for ChecksFollow-up, not in this PR
|
|
Review P2 on Tencent#710: the guard rejected only U+0000-U+001F while the error message and the docs promise every control character, so U+007F and the C1 range U+0080-U+009F still parsed. Range extended and the three ranges covered in the projects and roles fixtures.
|
The PR description includes a sufficient test plan and detailed real-CLI end-to-end matrix. |
Review P1 on Tencent#710: the guard tested for the exact strings '.' and '..', so '.. ', '.. .' and '...' passed. Win32 strips trailing spaces and periods from a path component, so each of those reaches the filesystem as '..' and escapes the namespace directory it was supposed to name. A segment of nothing but dots and spaces is '.' or '..' in disguise and is refused as such; 'a..' keeps parsing, since it stays inside its parent. The project id, whose allowlist already excluded spaces, refuses any run of dots for the same reason.
The PR description includes a sufficient test plan and detailed real-CLI end-to-end record. |
Review on Tencent#710. The dot/space fix reached further than it needed to: tightening the id to reject every run of dots also rejected '...', a working POSIX directory name the id rule has always accepted, so a manifest that parses today would have stopped. The id is back to the exact '.'/'..' check it had before this PR, with a test that says so. Only the namespace rule moves. The role docs claimed every namespace under resources: follows the rule, but roles.yaml's learnings: is accepted for backward compatibility and ignored at runtime -- it names no directory, so holding an old manifest to the rule would reject it over a field nothing reads. Both usage guides and the design doc now name the fields that do take effect.
|
Review P2 on Tencent#710: after the id was left on its old rule, the docs still described one rule for both, so they claimed a project id rejects any name made only of dots and spaces while '...' parses. Each rule now stands on its own in both usage guides and the design doc, and the projects.ts comment says why the id is not held to the namespace rule.
The PR description includes a detailed test plan and real-CLI end-to-end matrix, so testing documentation is sufficient. |
Review P1 on Tencent#710: refusing only names made entirely of dots and spaces left the aliasing half open. Win32 strips trailing periods and spaces from every path component, so 'frontend.', 'frontend ' and 'frontend..' all resolve to 'frontend' -- one namespace reading and writing another's directory, which is the isolation a namespace exists to provide. The rule is now the trailing character itself, which covers the escape ('.. ' arriving as '..') and the aliasing in one test, and '.' and '..' fall out of it. A dot inside a name ('alpha.v2') is untouched.
The PR description includes a detailed test plan and real-CLI e2e matrix, so testing documentation is sufficient. |
Review P1 on Tencent#710. resolveResourceNamespaces caught every failure from loadRolesManifest and carried on with no role filter, which for a member with no active project means an unfiltered sync: making the schema stricter would have turned 'skills: [../../evil]' into 'deliver every namespace', the opposite of what the guard is for. The catch was covering two cases at once, because loadRolesManifest throws both when the file is absent and when it is invalid. Only the first is the legacy, unfiltered case, so it now throws a typed RolesManifestMissingError and the catch reacts to that alone. An invalid manifest propagates and pull fails the scope with the entry named -- exactly what an invalid projects manifest already does. Verified against the real CLI: with 'skills: [evil/nested]' pushed to the team repo, pull reports the failed 'Skills to deliver can be resolved' check and the three delivered skills are left untouched; restoring the manifest syncs them again.
|
Findings
|
Review on Tencent#710, two of the three findings; the third was a stale read of the PR description, which the e2e section had already been rewritten to match and which is now updated before the push rather than after. readFileSafe returns null for every read failure and for an empty file, so an unreadable roles.yaml was indistinguishable from one that was never written -- and 'never written' is the one case allowed to relax role filtering. projects.yaml had the same hole, where a null manifest means 'this team is not partitioned'. Both loaders now read the file directly: ENOENT is absence, and a permission error, a directory or an empty file is an error that fails the pull. Windows opens a device for CON, NUL, AUX, PRN, COM0-9 and LPT0-9 in every directory, extension or not, so a namespace spelled that way cannot be the directory the manifest names. A name that merely starts like one (console, community) is untouched, and the project id stays out of this rule as it stays out of the others: it is a working POSIX name the id rule has always accepted.
The PR description includes a detailed test plan and real-CLI matrix, so no testing-description finding. |
…evice set Review on Tencent#710. The fail-closed change covered resolveResourceNamespaces but not the other callers that fall back when the loader throws, so a malformed manifest still reached an unfiltered sync by another route: bootstrap.ts left the member role-less while auto-selecting the sole role, resources/skills.ts and push.ts guessed the namespaces from the role ids, and config.ts skipped the legacy migration and left the role unset. Each now reacts to RolesManifestMissingError alone. roles-cmd.ts keeps its broad catches on purpose: those commands report the error to the person running them instead of deciding what to deliver. Windows reserves COM1-COM9 and LPT1-LPT9, not COM0/LPT0, so the guard was rejecting two ordinary directory names for no safety gain. Both are now covered by the test that pins 'console' and 'community' as valid.
The PR description includes a detailed test plan and real-CLI end-to-end matrix, so no testing-description finding. |
…devices Review on Tencent#710. ENOENT is not proof that a manifest is absent: a committed symlink whose target is missing reads exactly the same way, and absence is the one answer that lets a caller relax its filtering. The path is now lstat-ed before absence is believed, so a dangling link is an error like any other unreadable file. Windows reads the superscript forms of 1, 2 and 3 as device numbers, so COM and LPT followed by one of those join the ASCII-digit set. The third finding, that resolveResourceNamespaces returns before reading roles.yaml, is not a fail-open and is left as it is: that branch is reached only when the member has no role, and a role-less member gets the same unfiltered sync from a perfectly valid manifest, since every role namespace below is gated on primaryRole. Reading the manifest there would only add a new way for their pull to fail. The reasoning now sits in the code beside the early return.
|
Findings
Testing
|
…ust not Review on Tencent#710. Both init paths swallowed every role-selection failure and carried on without a role. A role-less config matches every role when hooks are reconciled, so a manifest that does not parse installed exactly the hooks it restricts. Narrowing the catch to RolesManifestMissingError alone was too much: the same block also absorbs a person skipping the role prompt, and a non-interactive run reaches it, so init would have started failing for anyone who does not pick a role. That case is now its own type, NoRoleSelectedError, and the two lenient cases are named while a parse failure or an unknown --role propagates. Both catch blocks read the same. Also: ENOENT proves nothing about absence when the DIRECTORY is a dangling link -- readFile and lstat on the file both report ENOENT -- so the path's components are walked, and the first link that leads nowhere is reported instead of being read as 'no manifest'. Verified in init.test.ts (malformed aborts and writes nothing, absent still initializes role-less) and against the real CLI in single-repo mode: no manifest exits 0 with the role unset, '../../evil' exits 1, and a valid manifest with --role sets primaryRole: frontend.
|
No findings. The PR description includes a sufficient test plan and detailed real-CLI end-to-end records covering all required agents and providers, plus malformed-manifest rejection and recovery scenarios. Per instruction, I reviewed the diff only and did not run or install anything. |
Review P1 on Tencent#710: the guard tested for the exact strings '.' and '..', so '.. ', '.. .' and '...' passed. Win32 strips trailing spaces and periods from a path component, so each of those reaches the filesystem as '..' and escapes the namespace directory it was supposed to name. A segment of nothing but dots and spaces is '.' or '..' in disguise and is refused as such; 'a..' keeps parsing, since it stays inside its parent. The project id, whose allowlist already excluded spaces, refuses any run of dots for the same reason.
Review on Tencent#710. The dot/space fix reached further than it needed to: tightening the id to reject every run of dots also rejected '...', a working POSIX directory name the id rule has always accepted, so a manifest that parses today would have stopped. The id is back to the exact '.'/'..' check it had before this PR, with a test that says so. Only the namespace rule moves. The role docs claimed every namespace under resources: follows the rule, but roles.yaml's learnings: is accepted for backward compatibility and ignored at runtime -- it names no directory, so holding an old manifest to the rule would reject it over a field nothing reads. Both usage guides and the design doc now name the fields that do take effect.
Review P2 on Tencent#710: after the id was left on its old rule, the docs still described one rule for both, so they claimed a project id rejects any name made only of dots and spaces while '...' parses. Each rule now stands on its own in both usage guides and the design doc, and the projects.ts comment says why the id is not held to the namespace rule.
Review P1 on Tencent#710: refusing only names made entirely of dots and spaces left the aliasing half open. Win32 strips trailing periods and spaces from every path component, so 'frontend.', 'frontend ' and 'frontend..' all resolve to 'frontend' -- one namespace reading and writing another's directory, which is the isolation a namespace exists to provide. The rule is now the trailing character itself, which covers the escape ('.. ' arriving as '..') and the aliasing in one test, and '.' and '..' fall out of it. A dot inside a name ('alpha.v2') is untouched.
Review P1 on Tencent#710. resolveResourceNamespaces caught every failure from loadRolesManifest and carried on with no role filter, which for a member with no active project means an unfiltered sync: making the schema stricter would have turned 'skills: [../../evil]' into 'deliver every namespace', the opposite of what the guard is for. The catch was covering two cases at once, because loadRolesManifest throws both when the file is absent and when it is invalid. Only the first is the legacy, unfiltered case, so it now throws a typed RolesManifestMissingError and the catch reacts to that alone. An invalid manifest propagates and pull fails the scope with the entry named -- exactly what an invalid projects manifest already does. Verified against the real CLI: with 'skills: [evil/nested]' pushed to the team repo, pull reports the failed 'Skills to deliver can be resolved' check and the three delivered skills are left untouched; restoring the manifest syncs them again.
Review on Tencent#710, two of the three findings; the third was a stale read of the PR description, which the e2e section had already been rewritten to match and which is now updated before the push rather than after. readFileSafe returns null for every read failure and for an empty file, so an unreadable roles.yaml was indistinguishable from one that was never written -- and 'never written' is the one case allowed to relax role filtering. projects.yaml had the same hole, where a null manifest means 'this team is not partitioned'. Both loaders now read the file directly: ENOENT is absence, and a permission error, a directory or an empty file is an error that fails the pull. Windows opens a device for CON, NUL, AUX, PRN, COM0-9 and LPT0-9 in every directory, extension or not, so a namespace spelled that way cannot be the directory the manifest names. A name that merely starts like one (console, community) is untouched, and the project id stays out of this rule as it stays out of the others: it is a working POSIX name the id rule has always accepted.
…evice set Review on Tencent#710. The fail-closed change covered resolveResourceNamespaces but not the other callers that fall back when the loader throws, so a malformed manifest still reached an unfiltered sync by another route: bootstrap.ts left the member role-less while auto-selecting the sole role, resources/skills.ts and push.ts guessed the namespaces from the role ids, and config.ts skipped the legacy migration and left the role unset. Each now reacts to RolesManifestMissingError alone. roles-cmd.ts keeps its broad catches on purpose: those commands report the error to the person running them instead of deciding what to deliver. Windows reserves COM1-COM9 and LPT1-LPT9, not COM0/LPT0, so the guard was rejecting two ordinary directory names for no safety gain. Both are now covered by the test that pins 'console' and 'community' as valid.
…devices Review on Tencent#710. ENOENT is not proof that a manifest is absent: a committed symlink whose target is missing reads exactly the same way, and absence is the one answer that lets a caller relax its filtering. The path is now lstat-ed before absence is believed, so a dangling link is an error like any other unreadable file. Windows reads the superscript forms of 1, 2 and 3 as device numbers, so COM and LPT followed by one of those join the ASCII-digit set. The third finding, that resolveResourceNamespaces returns before reading roles.yaml, is not a fail-open and is left as it is: that branch is reached only when the member has no role, and a role-less member gets the same unfiltered sync from a perfectly valid manifest, since every role namespace below is gated on primaryRole. Reading the manifest there would only add a new way for their pull to fail. The reasoning now sits in the code beside the early return.
…ust not Review on Tencent#710. Both init paths swallowed every role-selection failure and carried on without a role. A role-less config matches every role when hooks are reconciled, so a manifest that does not parse installed exactly the hooks it restricts. Narrowing the catch to RolesManifestMissingError alone was too much: the same block also absorbs a person skipping the role prompt, and a non-interactive run reaches it, so init would have started failing for anyone who does not pick a role. That case is now its own type, NoRoleSelectedError, and the two lenient cases are named while a parse failure or an unknown --role propagates. Both catch blocks read the same. Also: ENOENT proves nothing about absence when the DIRECTORY is a dangling link -- readFile and lstat on the file both report ENOENT -- so the path's components are walked, and the first link that leads nowhere is reported instead of being read as 'no manifest'. Verified in init.test.ts (malformed aborts and writes nothing, absent still initializes role-less) and against the real CLI in single-repo mode: no manifest exits 0 with the role unset, '../../evil' exits 1, and a valid manifest with --role sets primaryRole: frontend.
No code change. The Codex review workflow re-reviews on push, and the PR body now carries the real-CLI matrix run on the rebased head.
…d as fallback namespaces Review findings on Tencent#710 after the rebase. readManifestFile replaced readFileSafe/readFileIfExists, which expanded a home-relative repo.localPath. Without the expansion a documented `~/.teamai/...` path is searched under the current directory, read as absent, and roles.yaml absence relaxes the filtering. The path is expanded before both the read and the dangling-link walk. When roles.yaml is absent, skills.ts and push.ts fall back to the role ids as namespaces. A role id is an unrestricted string, so a value such as '../../outside' reached path.join, and SkillsHandler.removeItem could recurse outside the team repo. Both fallbacks now pass the ids through the namespace guard and fail with the rule's message.
A home-relative repo.localPath reached simple-git, the manifest readers and every resource path unexpanded, so `teamai pull` failed with 'Cannot use simple-git on a directory that does not exist'. The schema now expands it once, at parse time, so no consumer has to. expandHome moves to utils/home.ts (fs.ts re-exports it) so types.ts can import it without pulling in the fs helpers.
Review finding on Tencent#710. Windows opens the console for these names in any directory, extension or not, the way it does for CON, so a namespace spelled that way cannot be the directory the manifest means.
Review finding on Tencent#710. With a valid manifest that maps the role to several skill namespaces, silent push assigned primaryRole as the namespace without the check the fallback path already has. It now goes through the same guard, so 'frontend.' or 'CON' fail the push instead of becoming a path.
…he guard as breaking Review findings on Tencent#710. Two namespaces of one resource type that differ only by case (or Unicode normalization) name a single directory on the default Windows and macOS filesystems, so a role scoped to 'frontend' would read 'Frontend' too. Each manifest is checked when it loads; the pull path checks roles.yaml against projects.yaml as well, since both share skills/, knowledge/ and agents/. The namespace guard makes a manifest that parsed before fail every pull, so the changelog entry moves under Breaking Changes.
…rs too Review finding on Tencent#710. The cross-manifest case-alias check ran only when the member had a role, so a project-only member pulling skills/Common with a role's skills/common in the same repo was not stopped. roles.yaml is now read whenever a projects manifest is in play; an absent one stays absent, a broken one fails the pull as it does for a member with a role.
…aces Review finding on Tencent#710. The alias key was normalize('NFC').toLowerCase(), which is not case folding: 'σ'/'ς' and 's'/'ſ' stayed distinct although case-insensitive filesystems give each pair one directory. The key now upper- then lowercases each code point on its own, which folds both pairs and sidesteps the context-sensitive final-sigma rule. It errs toward joining ('ß'/'ss', 'ı'/'i'), which can only reject a pair.
Review finding on Tencent#710. migrateLegacyRoleConfig rethrew a manifest parse error, which loadLocalConfig caught and turned into null, so every command reported "teamai is not initialized" — pull included, leaving the member no way to fetch the fixed manifest. The migration now skips with a warning and returns the config unmigrated. That alone would widen delivery: a role-less member who would have been migrated to 'hai' reached resolveResourceNamespaces' unfiltered early return without roles.yaml being read. roles.yaml is now read for every member before that return, so a broken one fails the pull (absent still means unfiltered).
Found running the real CLI on Tencent#710. scanLocalForPush resolves namespaces through the roles manifest (agents via resolveResourceNamespaces, skills when it falls back to role ids), and a manifest that does not parse now throws there instead of being read as "no filter". status let that escape as a stack trace after printing half its report. Status is where a member looks to find out why pull failed, so it now warns with the error for that type and lists the rest, as it already does for git status.
|
Rebased onto RebaseOnly P1:
|
7e84b6a to
2d0bb35
Compare
|
Findings
The previously reported findings are resolved in the current diff. The PR description includes a sufficient final-head test plan and detailed real-CLI end-to-end matrix. |
… scanned Review finding on Tencent#710. With every successful scan empty and one type failing, status printed the warning and then "(none)", which reads as a complete clean result. It now says "(none in the types that could be scanned)" in that case.
…oped entry Review finding on Tencent#710. When the legacy role migration cannot read the roles manifest, the config stayed plainly role-less, and resolveMembership reads role-less as "every role": hooks, MCP servers and env variables scoped to roles reached a member the manifest would have made 'hai'. The pull refused the manifest for skills, but those reconcilers still ran. The migration now marks the in-memory config roleUnresolved, a runtime-only field like dataHome that serializeLocalConfig drops and the schema strips on load. activeRoleIds returns [] for it, so role-scoped entries reach nobody, unscoped ones apply as before, and the reconcilers remove role-scoped entries already installed. The next load decides the role again.
|
Both findings are addressed. Head is now P1: an unmigrated legacy config matched every role-scoped entryRight. Keeping the config loadable left it plainly role-less, and The fix keeps the fail-closed state explicit instead of skipping the reconcilers. Skipping would also leave previously installed role-scoped entries in place, and hold back unscoped ones: src/config.ts migrateLegacyRoleConfig, broken manifest
- return config // role-less → matches every role
+ return { ...config, roleUnresolved: true } // runtime-only, like dataHome: never written, stripped on load
src/roles.ts activeRoleIds
+ if (localConfig.roleUnresolved) return [] // matches no role-scoped entry; unscoped entries still apply
P2: "(none)" after a failed scanRight. When nothing else is pending but a type could not be scanned, status now prints Checks, final head
|
Reconcile the manifest namespace guard with Tencent#698 (push placement for rules and agents) and Tencent#699 (skills served from skill-data/). - Keep main's RolesManifestNotFoundError name with this branch's stricter loader (readManifestFile: only ENOENT without a dangling link is absent). - Import isSafeNamespaceSegment and NAMESPACE_RULE from manifest-schema in push.ts and push-namespaces.ts; drop the old ASCII wording from their namespace errors. - Port the role-id guards into main's placement: the legacy fallback with no roles.yaml and the silent default both refuse an unsafe role id. - pushCore reports a failing scan (e.g. an unparseable roles.yaml) with exit 2 instead of an uncaught stack trace; a projects manifest that cannot load for --project does the same. A legacy role that could not be resolved no longer places a new rule or agent at the shared root. - The skills scan rethrows only manifest load failures again; a valid manifest that no longer lists the role keeps the role-id fallback. - The learnings index uses the namespace rule contribute writes with, so a non-ASCII namespace is indexed. - Namespace errors quote the value and manifest read errors say what to do. - Docs, CHANGELOG and skill-data (troubleshooting, manage-admin) describe the broken-manifest behavior, including that --role cannot bypass it.
|
Thanks for all the iterations, @SaulMoro — solid work. |
|
@jeff-r2026 Brought up to date with This time it is a merge commit rather than a rebase. #698 rewrote the same The overlap with #698 went beyond the textual conflicts. Its placement code replaced the step that carried this PR's role-id guards, so those guards now live in |
Summary
A resource namespace becomes a directory component (
skills/<ns>/,agents/<ns>/,learnings/<ns>/) exactly as a project id does, but only the project id was refined at the manifest boundary. Bothmanifest/projects.yamlandmanifest/roles.yamlaccepted a namespace like../../evilunderresources:.The guard is about escaping the parent directory and nothing else, so it tests separators rather than spelling: a namespace that is a plain directory name keeps parsing, non-ASCII names and names holding a space included. A project id stays on the narrower ASCII rule it has always had, because it is also typed on the command line and split on commas (
teamai projects set a,b).Three Windows rules are folded in, because a namespace that is safe on macOS can still misbehave there:
:is refused with the separators —path.resolve(base, 'C:evil')is drive-relative and lands outsidebase..or space is refused, because Win32 strips those from every path component:..arrives as..and escapes the parent, whilefrontend.,frontendandfrontend..arrive asfrontendand land in another namespace's directory — the isolation the namespace exists to provide..and..fall out of the same rule; a dot inside a name (alpha.v2) is untouched.CON,NUL,AUX,PRN,CONIN$,CONOUT$,COM1–COM9,LPT1–LPT9, with or without an extension) is refused, since Windows opens a device for it in every directory. The superscript forms Windows also reads as device numbers go with them. A name that merely starts like one (console,community) is fine, and so areCOM0andLPT0, which Windows does not reserve.frontend,Frontend) are refused — compared under Unicode case folding, nottoLowerCase(), soσ/ςands/ſcount as one name too — because the default Windows and macOS filesystems give both the same directory, so a role scoped to one would read the other's resources — the same isolation failure as the trailing-dot aliases. Each manifest is checked as it loads, and the pull path checksroles.yamlagainstprojects.yamltoo, since both shareskills/,knowledge/andagents/. That cross-check runs whenever a projects manifest is in play, for a member with no role as much as for one with a role — the collision is in the repo, not in who pulls — soroles.yamlis read for a project-only member too: absent stays absent, broken fails the pull. The same spelling used twice, or the same name under two resource types, is fine.The existing
isSafeNamespaceSegmentguards incontribute.tsandresources/agents.tsstay as a second line, now importing the one definition. Theprojects.tsdoc comment said the check lived at the manifest boundary and that hand-editedconfig.yamlids were guarded at use sites; an id fromconfig.yamlresolves throughgetProjectOrThrow, so it can only name a project the manifest already validated. The comment now says so.Running the CLI against a bad manifest showed a second gap:
ProjectsManifestSchema.parse()threw a rawZodErrorthat escaped to the top level, soteamai pullprinted a dumped validation object where every hand-written check beside it (duplicate project id,unknown resource type) prints a sentence.parseManifestnow names the entry:The rule covers the namespaces that name a directory:
knowledge,skills,agentsin both manifests, andlearningsinprojects.yaml. A role'slearnings:is accepted for backward compatibility and ignored at runtime, so it names nothing and stays unchecked — holding an old manifest to the rule over a field nothing reads would reject it for no gain.Stricter parsing had to come with a second change, or it would have worked backwards.
resolveResourceNamespacescaught every failure fromloadRolesManifestand continued with no role filter, which for a member with no active project is an unfiltered sync — soskills: ['../../evil']would have delivered every namespace the manifest exists to gate. The catch covered two cases at once, because the loader threw both for an absent file and for an invalid one. Only the first is the legacy unfiltered case; it now throws a typedRolesManifestNotFoundError(the name #698 gave the same case; this PR called itRolesManifestMissingErroruntil the merge) and the catch reacts to that alone, while an invalid manifest fails the scope's pull exactly as an invalid projects manifest already does."Absent" had to be narrowed to mean what it says.
readFileSafereturnsnullfor every read failure and for an empty file, so an unreadableroles.yamllooked identical to one that was never written, andprojects.yamlhad the same hole on the path where a missing manifest means "this team is not partitioned". Both loaders now read the file directly:ENOENTis absence, anything else — a permission error, a directory, an empty file — is an error that fails the pull.ENOENTalone is not taken as proof, either: a committed symlink with no target reads the same way, whether it is the file ormanifest/itself, so the path's components are walked before absence is believed.roles.yamlis read for every member, role or no role, beforeresolveResourceNamespacesdecides anything. An earlier round skipped it for a member with no role, no project and no projects manifest, on the reasoning that the manifest gates nothing for them. It does: a role-less config is migrated to a manifest-declaredhairole when the manifest parses, so a broken manifest let that member fall through to an unfiltered sync. Absent still means unfiltered; broken fails the pull.Every caller that falls back when the manifest cannot be loaded now reacts to
RolesManifestNotFoundErroralone, not to any error:bootstrap.ts(auto-selecting the sole role, where swallowing left the member role-less and therefore unfiltered),resources/skills.tsandpush.ts(both guessing namespaces from the role ids), andinit.tsin single-repo mode, where continuing role-less meant reconciling hooks against a config that matches every role.roles-cmd.tskeeps its broad catches: those commands print the error to the person running them rather than deciding what to deliver.The legacy role migration (
config.ts) is the one caller that must not fail on a broken manifest. It runs insideloadLocalConfig, which every command goes through, and turned the rethrown error intonull: every command then reportedteamai is not initialized, andpullcould not fetch the corrected manifest, so the member stayed stuck until someone rangit pullin the clone by hand. The migration now warns (Legacy role migration skipped: Invalid roles manifest: …) and returns the config unmigrated, and the pull fails closed on its own, as above. Unmigrated cannot mean plainly role-less, though:resolveMembershipreads that as every role, so hooks, MCP servers and env variables scoped byroles:reached a member the manifest would have madehai, after the pull had already refused the manifest for skills. The in-memory config carriesroleUnresolvedinstead, a runtime-only field likedataHomethat is never written (serializeLocalConfigdrops it, the schema strips it on load), andactiveRoleIdsreturns[]for it. Role-scoped entries reach nobody, and the reconcilers withdraw any already installed; unscoped ones apply as before. The next load decides the role again.teamai statushad the same shape one level down: a handler whosescanLocalForPushresolves namespaces through the manifest threw, and status died with a stack trace halfway through its report. It now prints[<type>] could not scan: <error>and lists the rest, and when nothing else is pending it says(none in the types that could be scanned)rather than a bare(none).Two more findings after the rebase.
readManifestFilereplacedreadFileSafe/readFileIfExists, which expanded a home-relativerepo.localPath; without that, a documented~/.teamai/...path was searched under the current directory, read as absent, and the absence relaxed the filtering. Checking where else that path goes unexpanded showed it never worked end to end:simple-gitreceived it as-is andteamai pullfailed withCannot use simple-git on a directory that does not exist. So the expansion moved to the one place every consumer shares —LocalConfigSchemaexpandsrepo.localPathat parse time, andsimple-git, the manifest readers and every resource path see an absolute path.expandHomeitself moved toutils/home.ts(utils/fs.tsre-exports it) sotypes.tscan import it without the fs helpers. And the two places that fall back to role ids as namespaces whenroles.yamlis absent (resources/skills.ts,push.ts) took the ids as they were; a role id is an unrestricted string, so'../../outside'reachedpath.join, andSkillsHandler.removeItemcould recurse outside the team repo. Both fallbacks now run the ids through the namespace guard and fail with its message, and so does the one remaining place a role id became a namespace without going through the manifest: silentpushwith a role that maps to several skill namespaces, which assignedprimaryRoledirectly.Docs: the rule is stated wherever either manifest is documented —
docs/usage-guide.mdanddocs/usage-guide.zh-CN.md(projects section and theroles.yamlexample), plusdocs/designs/multi-project-management.md.grep -rl "resources:" docs README*.mdreturns exactly those three files.Split out of #700 at the reviewer's request: it is the one change there that can make a manifest that parses today stop parsing.
Type of Change
A team whose manifest already ships a namespace containing
/,\,:, a control character, a trailing./space, a Windows device name, or a pair differing only by case gets a parse error after this, and every pull fails until the entry and its directory are renamed together — which is why this is filed as breaking and the changelog entry sits under Breaking Changes. So does a team whoseroles.yamlorprojects.yamlis present but empty or unreadable — previously that silently meant "no manifest". No fixture or e2e manifest in the repo does, and such a namespace already fails at thecontributeand agents guards, so nothing that works today stops working.hai-inference,alpha.v2,alpha_shared...included研发,team frontend,team@frontend../../evil,a/b,x\y.,....,...,(Win32 aliases of..)frontend.,frontend,frontend..(Win32 aliases offrontend)CON,nul,COM1,CON.txt,CONIN$,CONOUT$.txt(Windows devices)console,community,COM0,LPT0(not reserved)frontend+Frontendunder one resource type (in one manifest, or role vs project)ΟΔΟΣ+οδοσ,ſkills+skills(one name under Unicode case folding)frontendunderskills+FrontendunderagentsC:evilThe id and the namespace are two rules, not one: an id stays on the ASCII allowlist plus the exact
./..check it has always had, and only the namespace rule moves. The rows above other than the project-id row are namespaces.Test Plan
npx tsc --noEmitpassesnpx vitest runpassesgit,gitlab,github(final headf3f8dcd, below; earlier heads further down)Merge with
main: #698, #699, #692, #740 (headf3f8dcd)#698 rewrote push placement: new rules and agents now go to a namespace from
--role/--project, and it added its own split between an absent and an unreadableroles.yaml. The two changes overlap insrc/roles.tsandsrc/push.ts. #699 moved the skill content toskill-data/, and the repo rule now asks behavior changes to update it. #692 and #740 merged with no conflicts.One behavior from #698 changes. #698 documented
--role <ns>as the way past aroles.yamlthat "cannot answer". The skills scan needs the manifest to tell which namespaces are the member's, so aroles.yamlthat cannot be read or parsed, or is empty, now stopspushat the scan with exit 2, even with--role.--rolestill works for a manifest that just lacks the configured role.docs/usage-guide.md,docs/usage-guide.zh-CN.mdand the CHANGELOG say this now. One #698 e2e assertion changed accordingly.push --allagainst an unparseableroles.yamlstill expects exit 2 and an empty branch, but its text check moved fromCannot resolve where new rules should gotoInvalid roles manifest YAML, plus a check that no stack trace is printed./code-reviewran on the merge (Standards and Spec axes) before the commit. The rows above marked "was:" are its findings, now fixed. Deferred, since each predates this merge and belongs in its own change:push --rolestill validates with the ASCIIassertSafeResourceName. It therefore rejects研发and acceptsCONandfrontend..Before (merge with conflicts resolved, no follow-ups):
After (
f3f8dcd):Real-CLI matrix against
dist/index.js. Each cell is a fresh sandbox with a bare-repo remote, run for Claude, Codex, CodeBuddy and OpenCode.gitlabruns against a local fake API andgithubagainst a stubgh, the same waypush-namespace-e2e.test.tsdoes. No real host was contacted in this round.gitgitlabgithubpull, valid manifests: the role's skill is deliveredpull,skills: ['../evil']: scope refused, value quoted (got "../evil"), nothing deliveredpull, emptyroles.yaml: error, not read as absentpull,skills: [be-skills, Be-Skills]: case alias refusedpush --all, unparseableroles.yaml: exit 2, no stack trace, no branchpush --all --role pm, unparseableroles.yaml: exit 2, no branchpush --all, noroles.yaml, role idCON: exit 2, no branchpush --project front-app --all:rules/fe-know/,skills/fe-skills/,agents/fe-agents/teamai skill get core --fullandteamai skill get setup --fullserve the updated troubleshooting item and the namespace rule. Apullthat refuses a scope still exits 0 and prints the error for that scope.Door: two-way. Reverting the merge commit restores the pre-merge head, and nothing is written that a revert cannot undo.
Blast radius: team-wide. Every
pullandpushreads these manifests. A team whose manifest breaks the rule, or is empty or unreadable, fails until an admin fixes it, as the Type of Change section above says. The merge adds only thepushside of that: exit 2 and no--rolebypass.Rebase onto
mainafter #713, and the review that followedRebased onto
a52374a(#718, #713, #736, #739). Two files conflicted:git range-diffshows every other patch unchanged. One interaction was checked rather than assumed: #713 turns the role prompt without a terminal intoError('… Pass --role <id> …'). That is a plain error, notNoRoleSelectedError, so both init paths still stop on it, as they did before #713, whenaskQuestionalready rejected without a TTY; they now stop with #713's clearer message.Review commits since:
1ce9c4c(Unicode case folding for the alias key),e6ef558(the legacy migration keeps the config loadable;roles.yamlread before the role-less early return),2d0bb35(status reports a type it cannot scan),0f80dcc(no bare(none)when a type was not scanned),2965e4d(an unresolved role matches no role-scoped entry). Everything below is on the final head,2965e4d; the real-CLI runs used a build of25d2298, which differs from it only inCHANGELOG.md.Earlier rebase onto
mainafter #700#700 landed while this PR was open and touched the same loaders. Rebased onto
cd3e0e6; every conflict was insrc/roles.ts,src/projects.tsand their tests, and none changed what either side meant:loadRolesManifestIfPresentkeeps the contract #700 gave it —nullonly for an absent file, throw for an unreadable one — and gains this PR's empty-file rejection, somembership.tsreads the same manifest the pull does. #700's own tests for that helper (absent →null, mode-000 → throws) pass unchanged.Four review commits followed the rebase:
198e3e2(expand~in the manifest reader; guard role ids used as fallback namespaces),86890d6(expand~once, inLocalConfigSchema),d4789c2(CONIN$/CONOUT$),12b6b69(guard the role id silentpushuses as a namespace),9797f14(case-aliased namespaces; breaking classification),7e84b6a(the cross-manifest check for role-less project members). Those ran on7e84b6a; the results below are from the final head.Real-CLI matrix (final head)
Fixture team repos with the same three namespaces —
common(rolefrontend),hai-inferenceand研发(projecthai-inference) — each carrying a skill, plus agents undercommonand研发and a project-private learning. Each cell is a fresh directory:teamai init <repo> --agent <A> --role frontend --project hai-inference --scope project --force, thenteamai pull --force.gitgitlabgithubEvery cell: init exit 0, pull exit 0, and
teamai projects listprints the namespace unchanged (skills: hai-inference, 研发). The three team skills land under.<agent>/skills/; the two legacy.mdagents land under.claude/agentsand.codebuddy/agents, which isLEGACY_MD_TOOLSbehavior onmain, not this PR. Thegitcolumn is a generic host (the fixture reached through an ssh alias that no provider recognizes).teamai push --all --role hai-inferencefrom the Claude cell:github—Pushed branch teamai/push/SaulMoro/…,Pull Request createdon the fixture, exit 0.git— branch pushed,Failed to create PR: Automatic pull/merge request creation is not supported for generic Git hosts, exit 1, as documented.Rejection and recovery (final head)
Each manifest change pushed to the
githubfixture and observed from the Claude cell withteamai pull --force:A legacy member: user scope,
primaryRoleandprojectsremoved fromconfig.yamlafterinit, and a team repo with noprojects.yamlwhoseroles.yamldeclareshaiand afrontendrole withskills: ['../../evil']. Run in a temporaryHOMEagainst thegitfixture, old head7e84b6aagainst2d0bb35:(
claude is installedalso fails in that run, because the temporaryHOMEhas no~/.claude.) It predates0f80dccand2965e4d, which change only the(none)line and role-scoped entries, neither of which it has. The same member in project scope, against thegithubfixture, gets the same fail-closed pull and recovers the same way.The same legacy member with role-scoped entries in the team repo:
mcp/mcp.yamldeclarespublic-mcp(unscoped) andhai-mcp(roles: [hai]),env/env.yamldeclaresPUBLIC_VARandHAI_SECRET(roles: [hai]), androles.yamlis broken as above.2d0bb35against the final build, in one temporaryHOME(with~/.claudepresent, so Claude receives MCP servers):repo.localPath: ~/.teamai/projects/<hash>/team-repoin the Claude cell'sconfig.yaml:Single-repo
initagainst a broken manifestSelf-mode
initswallowed every role-selection failure, so a malformed manifest left the config role-less and hooks reconciled as if the member matched every role. Run against a real clone with a genericgitremote:Two cases stay lenient by type, not by message: a repo with no manifest, and a person who skipped the role prompt by answering it empty (
NoRoleSelectedError) — that second one is why the catch cannot simply be narrowed to the missing-manifest error. A run with no terminal never reaches that prompt's answer: it stops with #713'sPass --role <id>error. Both init paths, single-repo and normal, now read the same. Covered by two cases ininit.test.tsalongside the real-CLI run above.The role-id guards have no fixture path — a role id in
config.yamlis whateverinitwrote — so they are covered by unit tests:scanLocalForPushwithprimaryRole: '../../outside'and noroles.yaml,push --allin the same state, andpush --all --silentwithprimaryRole: 'CON'against a valid manifest, each fail withInvalid role id used as a skills namespace "…"and push nothing.Related Issues
Split from #700 (for #668).