Skip to content

fix(manifest): reject namespace strings that are not safe path segments - #710

Merged
jeff-r2026 merged 27 commits into
Tencent:mainfrom
SaulMoro:fix/manifest-namespace-segments
Sep 23, 2026
Merged

jeff-r2026 merged 27 commits into
Tencent:mainfrom
SaulMoro:fix/manifest-namespace-segments

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

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. Both manifest/projects.yaml and manifest/roles.yaml accepted a namespace like ../../evil under resources:.

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).

 src/manifest-schema.ts (new)
+  isSafeNamespaceSegment      no '/', '\', ':', no control char, no dots-and-spaces name
+  NamespaceSegmentSchema      z.string().min(1).refine(isSafeNamespaceSegment)
+  parseManifest               schema failure → one line naming the offending entry
+  readManifestFile            ENOENT + no dangling link = absent; else throw
+  assertSafeFallbackNamespaces role ids standing in for namespaces get the same guard
+  assertNoCaseAliasedNamespaces  no two namespaces of one type differ only by case (per manifest, and roles × projects on pull)
+  caseFoldKey                 per code point upper→lower: Unicode case folding, not toLowerCase()
 src/resource-namespaces.ts
-  role-less, no project, no projects.yaml → return null before roles.yaml is read
+  roles.yaml read for every member first; absent → null as before, broken → fail the pull
 src/config.ts
-  migrateLegacyRoleConfig, broken manifest → rethrow (loadLocalConfig turned it into "not initialized")
+  migrateLegacyRoleConfig, broken manifest → warn, return the config unmigrated with roleUnresolved (runtime-only)
 src/roles.ts
+  activeRoleIds({ roleUnresolved })   → []   (no role-scoped entry matches; role-less null still matches all)
 src/status.ts
+  scanLocalForPush throws → warn "[<type>] could not scan: …", list the other types
+  nothing new but a type unscanned → "(none in the types that could be scanned)", not "(none)"
 src/resources/skills.ts, src/push.ts
-  roles.yaml absent           → [primaryRole, ...additionalRoles] joined as-is
+  roles.yaml absent           → assertSafeFallbackNamespaces([...]) or fail the command
-  push --silent, >1 namespace → primaryRole joined as-is
+  push --silent, >1 namespace → role id must pass the namespace rule, or the push stops (resolveNamespaceForNew since #698)
 src/types.ts
-  LocalConfigSchema.repo.localPath   z.string()
+  LocalConfigSchema.repo.localPath   z.string().transform(expandHome)   // once, for every consumer
 src/projects.ts
-  ProjectSchema.id            refine(isSafeNamespaceSegment)   // ASCII allowlist, shared
-  resources.*                 z.string().min(1)
+  ProjectSchema.id            refine(isSafeProjectId)          // ASCII allowlist, id only
+  resources.*                 z.array(NamespaceSegmentSchema)
 src/roles.ts
-  resources.{knowledge,skills,agents}   z.string().min(1)
+  resources.*                 z.array(NamespaceSegmentSchema)

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 outside base.
  • A trailing . or space is refused, because Win32 strips those from every path component: .. arrives as .. and escapes the parent, while frontend., frontend and frontend.. arrive as frontend and 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.
  • A device name (CON, NUL, AUX, PRN, CONIN$, CONOUT$, COM1COM9, LPT1LPT9, 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 are COM0 and LPT0, which Windows does not reserve.
  • Two namespaces of one resource type that differ only by case (frontend, Frontend) are refused — compared under Unicode case folding, not toLowerCase(), so σ/ς and s/ſ 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 checks roles.yaml against projects.yaml too, since both share skills/, knowledge/ and agents/. 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 — so roles.yaml is 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 isSafeNamespaceSegment guards in contribute.ts and resources/agents.ts stay as a second line, now importing the one definition. The projects.ts doc comment said the check lived at the manifest boundary and that hand-edited config.yaml ids were guarded at use sites; an id from config.yaml resolves through getProjectOrThrow, 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 raw ZodError that escaped to the top level, so teamai pull printed a dumped validation object where every hand-written check beside it (duplicate project id, unknown resource type) prints a sentence. parseManifest now names the entry:

✖ [project] Invalid projects manifest: projects.0.resources.skills.1: resource namespace must be
  a single path segment (no '/', '\', ':' or control characters, no trailing '.' or space,
  which also rules out '.' and '..', and not a Windows device name such as 'CON' or 'COM1')

The rule covers the namespaces that name a directory: knowledge, skills, agents in both manifests, and learnings in projects.yaml. A role's learnings: 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. resolveResourceNamespaces caught every failure from loadRolesManifest and continued with no role filter, which for a member with no active project is an unfiltered sync — so skills: ['../../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 typed RolesManifestNotFoundError (the name #698 gave the same case; this PR called it RolesManifestMissingError until 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. readFileSafe returns null for every read failure and for an empty file, so an unreadable roles.yaml looked identical to one that was never written, and projects.yaml had the same hole on the path where a missing manifest means "this team is not partitioned". Both loaders now read the file directly: ENOENT is absence, anything else — a permission error, a directory, an empty file — is an error that fails the pull. ENOENT alone is not taken as proof, either: a committed symlink with no target reads the same way, whether it is the file or manifest/ itself, so the path's components are walked before absence is believed.

roles.yaml is read for every member, role or no role, before resolveResourceNamespaces decides 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-declared hai role 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 RolesManifestNotFoundError alone, not to any error: bootstrap.ts (auto-selecting the sole role, where swallowing left the member role-less and therefore unfiltered), resources/skills.ts and push.ts (both guessing namespaces from the role ids), and init.ts in single-repo mode, where continuing role-less meant reconciling hooks against a config that matches every role. roles-cmd.ts keeps 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 inside loadLocalConfig, which every command goes through, and turned the rethrown error into null: every command then reported teamai is not initialized, and pull could not fetch the corrected manifest, so the member stayed stuck until someone ran git pull in 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: resolveMembership reads that as every role, so hooks, MCP servers and env variables scoped by roles: reached a member the manifest would have made hai, after the pull had already refused the manifest for skills. The in-memory config carries roleUnresolved instead, a runtime-only field like dataHome that is never written (serializeLocalConfig drops it, the schema strips it on load), and activeRoleIds returns [] 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 status had the same shape one level down: a handler whose scanLocalForPush resolves 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. readManifestFile replaced readFileSafe/readFileIfExists, which expanded a home-relative repo.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-git received it as-is and teamai pull failed with Cannot use simple-git on a directory that does not exist. So the expansion moved to the one place every consumer shares — LocalConfigSchema expands repo.localPath at parse time, and simple-git, the manifest readers and every resource path see an absolute path. expandHome itself moved to utils/home.ts (utils/fs.ts re-exports it) so types.ts can import it without the fs helpers. And the two places that fall back to role ids as namespaces when roles.yaml is absent (resources/skills.ts, push.ts) took the ids as they were; a role id is an unrestricted string, so '../../outside' reached path.join, and SkillsHandler.removeItem could 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: silent push with a role that maps to several skill namespaces, which assigned primaryRole directly.

Docs: the rule is stated wherever either manifest is documented — docs/usage-guide.md and docs/usage-guide.zh-CN.md (projects section and the roles.yaml example), plus docs/designs/multi-project-management.md. grep -rl "resources:" docs README*.md returns 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

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

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 whose roles.yaml or projects.yaml is 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 the contribute and agents guards, so nothing that works today stops working.

namespace before after
hai-inference, alpha.v2, alpha_shared parses parses
any project id the old rule accepted, ... included parses parses
研发, team frontend, team@frontend parses parses
../../evil, a/b, x\y parses rejected
., .. parses rejected
.. , ..., (Win32 aliases of ..) parses rejected
frontend., frontend , frontend.. (Win32 aliases of frontend) parses rejected
CON, nul, COM1, CON.txt, CONIN$, CONOUT$.txt (Windows devices) parses rejected
console, community, COM0, LPT0 (not reserved) parses parses
frontend + Frontend under one resource type (in one manifest, or role vs project) parses rejected
ΟΔΟΣ + οδοσ, ſkills + skills (one name under Unicode case folding) parses rejected
frontend under skills + Frontend under agents parses parses
C:evil parses rejected
any name holding a control character (C0, DEL, C1) parses rejected

The 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 --noEmit passes
  • npx vitest run passes
  • Added/updated tests for the change
  • Real-CLI end-to-end matrix: Claude, Codex, CodeBuddy, OpenCode × git, gitlab, github (final head f3f8dcd, below; earlier heads further down)

Merge with main: #698, #699, #692, #740 (head f3f8dcd)

#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 unreadable roles.yaml. The two changes overlap in src/roles.ts and src/push.ts. #699 moved the skill content to skill-data/, and the repo rule now asks behavior changes to update it. #692 and #740 merged with no conflicts.

 src/roles.ts
-  RolesManifestMissingError (this PR)  +  RolesManifestNotFoundError (#698, readFileSafe + pathExists)
+  RolesManifestNotFoundError, thrown by this PR's readManifestFile (ENOENT and no dangling link only)
 src/push.ts
-  this PR's resolveSkillNamespaces + old step 4 carried the role-id guards
+  #698's namespaceCandidates / resolveNamespaceForNew, with the guards moved in:
+    no roles.yaml → role id must pass the namespace rule, else unresolvable (exit 2)
+    silent default with >1 candidate → same
+    roleUnresolved → unresolvable          (was: new rule/agent placed at the shared root)
+  pushCore scan: a handler that throws → log.error, exit 2   (was: uncaught stack trace, exit 1)
+  --project: projects.yaml that cannot load → log.error, exit 2   (was: stack trace)
 src/push.ts, src/push-namespaces.ts
-  isSafeNamespaceSegment from ./projects.js (no longer exported); errors state the ASCII rule
+  imported from ./manifest-schema.js; errors state NAMESPACE_RULE
 src/resources/skills.ts
-  any error from the manifest load or the role lookup → rethrow
+  manifest load fails → rethrow; valid manifest that lacks the role → role-id fallback, as on main
 src/utils/search-index.ts
-  learnings namespace: /^[A-Za-z0-9._-]+$/     (contribute writes learnings/研发/, the index skipped it)
+  isSafeNamespaceSegment
 src/manifest-schema.ts
+  NamespaceSegmentSchema message ends with `; got "<value>"`
+  read, dangling-link, empty and case-alias errors say what to do next
 skill-data/                                                        (#699)
+  core/references/troubleshooting.md   broken manifest: the scope is skipped on purpose, ask an admin
+  setup/references/manage-admin.md     the namespace rule, and why deleting roles.yaml is not a fix

One behavior from #698 changes. #698 documented --role <ns> as the way past a roles.yaml that "cannot answer". The skills scan needs the manifest to tell which namespaces are the member's, so a roles.yaml that cannot be read or parsed, or is empty, now stops push at the scan with exit 2, even with --role. --role still works for a manifest that just lacks the configured role. docs/usage-guide.md, docs/usage-guide.zh-CN.md and the CHANGELOG say this now. One #698 e2e assertion changed accordingly. push --all against an unparseable roles.yaml still expects exit 2 and an empty branch, but its text check moved from Cannot resolve where new rules should go to Invalid roles manifest YAML, plus a check that no stack trace is printed.

/code-review ran 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 --role still validates with the ASCII assertSafeResourceName. It therefore rejects 研发 and accepts CON and frontend..

Before (merge with conflicts resolved, no follow-ups):

npx tsc --noEmit        6 errors   (isSafeNamespaceSegment not exported from projects.js; error class name)
npx vitest run          3 failed   (2 role-id guards lost with the old step 4; roles.test "could not be read")
test:e2e                2 failed   push --all, broken roles.yaml → exit 1:
  - Scanning local resources...
  file:///…/dist/index.js:1840
      throw new Error(`Invalid roles manifest YAML: ${error.message}`);

After (f3f8dcd):

npx tsc --noEmit                              clean
npx vitest run                                4289 passed, 1 skipped   (290 files)
npx vitest run --config vitest.e2e.config.ts  224 passed, 26 skipped   (44 files, 3 skipped: live providers without credentials)

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. gitlab runs against a local fake API and github against a stub gh, the same way push-namespace-e2e.test.ts does. No real host was contacted in this round.

scenario git gitlab github
pull, valid manifests: the role's skill is delivered 4/4 (exit 0) 4/4 4/4
pull, skills: ['../evil']: scope refused, value quoted (got "../evil"), nothing delivered 4/4 4/4 4/4
pull, empty roles.yaml: error, not read as absent 4/4 4/4 4/4
pull, skills: [be-skills, Be-Skills]: case alias refused 4/4 4/4 4/4
push --all, unparseable roles.yaml: exit 2, no stack trace, no branch 4/4 4/4 4/4
push --all --role pm, unparseable roles.yaml: exit 2, no branch 4/4 4/4 4/4
push --all, no roles.yaml, role id CON: exit 2, no branch 4/4 4/4 4/4
push --project front-app --all: rules/fe-know/, skills/fe-skills/, agents/fe-agents/ 4/4 (exit 1: generic host, no PR) 4/4 (MR created) 4/4 (PR created)

teamai skill get core --full and teamai skill get setup --full serve the updated troubleshooting item and the namespace rule. A pull that 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 pull and push reads 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 the push side of that: exit 2 and no --role bypass.

Rebase onto main after #713, and the review that followed

Rebased onto a52374a (#718, #713, #736, #739). Two files conflicted:

 CHANGELOG.md        every commit that edits this PR's entry: keep #713's line above it, take this PR's text
 src/init.ts
-  import { describeRoles, listRoleIds, loadRolesManifest }                  (#713)
-  import { describeRoles, loadRolesManifest, RolesManifestMissingError }    (this PR)
+  import { describeRoles, listRoleIds, loadRolesManifest, RolesManifestMissingError }

git range-diff shows every other patch unchanged. One interaction was checked rather than assumed: #713 turns the role prompt without a terminal into Error('… Pass --role <id> …'). That is a plain error, not NoRoleSelectedError, so both init paths still stop on it, as they did before #713, when askQuestion already 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.yaml read 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 of 25d2298, which differs from it only in CHANGELOG.md.

npx tsc --noEmit                              clean
npm run build                                 ok
npx vitest run                                3942 passed, 1 skipped, 0 failed   (276 files)
npx vitest run --config vitest.e2e.config.ts  41 files passed, 3 skipped (live-provider files without credentials)

Earlier rebase onto main after #700

#700 landed while this PR was open and touched the same loaders. Rebased onto cd3e0e6; every conflict was in src/roles.ts, src/projects.ts and their tests, and none changed what either side meant:

 src/roles.ts
   loadRolesManifestIfPresent            (added by #700, used by membership.ts)
-    readFileIfExists(path) === null → null; else loadRolesManifest()
+    try loadRolesManifest() catch RolesManifestMissingError → null; else rethrow
   import readFileSafe, readFileIfExists  (dropped: readManifestFile covers both)
 src/projects.ts
-  readFileIfExists(manifestPath)         (#700)
+  readManifestFile(manifestPath, 'projects')
 src/__tests__/roles.test.ts
-  import matchesRoles                    (#700 removed the function; import was stale)

loadRolesManifestIfPresent keeps the contract #700 gave it — null only for an absent file, throw for an unreadable one — and gains this PR's empty-file rejection, so membership.ts reads 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, in LocalConfigSchema), d4789c2 (CONIN$/CONOUT$), 12b6b69 (guard the role id silent push uses as a namespace), 9797f14 (case-aliased namespaces; breaking classification), 7e84b6a (the cross-manifest check for role-less project members). Those ran on 7e84b6a; the results below are from the final head.

Real-CLI matrix (final head)

Fixture team repos with the same three namespaces — common (role frontend), hai-inference and 研发 (project hai-inference) — each carrying a skill, plus agents under common and 研发 and a project-private learning. Each cell is a fresh directory: teamai init <repo> --agent <A> --role frontend --project hai-inference --scope project --force, then teamai pull --force.

agent git gitlab github
Claude pass pass pass
Codex pass pass pass
CodeBuddy pass pass pass
OpenCode pass pass pass

Every cell: init exit 0, pull exit 0, and

✔ [project] Synced 3 skills (3 new, 0 updated)
    new: common-guide, inference-guide, rnd-guide
✔ [project] Synced 2 agents
✔ Synced 1 learnings

teamai projects list prints the namespace unchanged (skills: hai-inference, 研发). The three team skills land under .<agent>/skills/; the two legacy .md agents land under .claude/agents and .codebuddy/agents, which is LEGACY_MD_TOOLS behavior on main, not this PR. The git column is a generic host (the fixture reached through an ssh alias that no provider recognizes).

teamai push --all --role hai-inference from the Claude cell:

  • githubPushed branch teamai/push/SaulMoro/…, Pull Request created on 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 github fixture and observed from the Claude cell with teamai pull --force:

projects.yaml skills: [hai-inference, '../../evil']
  ✖ [project] Invalid projects manifest: projects.0.resources.skills.1: resource namespace must be
    a single path segment (no '/', '\', ':' or control characters, no trailing '.' or space,
    which also rules out '.' and '..', and not a Windows device name such as 'CON' or 'COM1')
  ⚠   ✖ Skills to deliver can be resolved
roles.yaml skills: ['evil/nested']
  ✖ [project] Invalid roles manifest: roles.0.resources.skills.0: …
  delivered before: common-guide inference-guide rnd-guide
  delivered after:  common-guide inference-guide rnd-guide
roles.yaml skills: [common, 'hai-inference.']          (Win32 alias)
  ✖ [project] Invalid roles manifest: roles.0.resources.skills.1: …
roles.yaml skills: [common, 'CONOUT$.txt']             (console device)
  ✖ [project] Invalid roles manifest: roles.0.resources.skills.1: …
projects.yaml skills: [hai-inference, 研发, Common]      (case alias of the role's `common`)
  ✖ [project] Invalid manifests (roles.yaml with projects.yaml): skills namespaces "common" (role frontend)
    and "Common" (project hai-inference) differ only by case or Unicode normalization and would name
    the same directory on a case-insensitive filesystem
  ⚠   ✖ Skills to deliver can be resolved
  — same rejection from a second cell initialised with --project hai-inference and no primaryRole;
    restored, that cell syncs its 2 project skills and 1 agent (no role → no `common`)
both restored
  ✔ Synced 3 skills (all updated)  ✔ Synced 2 agents  ✔ Synced 1 learnings   exit 0
roles.yaml skills: [common, ΟΔΟΣ] + projects.yaml skills: [hai-inference, 研发, οδοσ]   (Unicode case folding)
  ✖ [project] Invalid manifests (roles.yaml with projects.yaml): skills namespaces "ΟΔΟΣ" (role frontend)
    and "οδοσ" (project hai-inference) differ only by case or Unicode normalization …
  ⚠   ✖ Skills to deliver can be resolved

A legacy member: user scope, primaryRole and projects removed from config.yaml after init, and a team repo with no projects.yaml whose roles.yaml declares hai and a frontend role with skills: ['../../evil']. Run in a temporary HOME against the git fixture, old head 7e84b6a against 2d0bb35:

pull that fetches the broken manifest
  ✖ [user] Invalid roles manifest: roles.1.resources.skills.0: …
  ⚠   ✖ Skills to deliver can be resolved            no team skill delivered
7e84b6a  status   ✖ Invalid local config: Invalid roles manifest: …
                  Error: teamai is not initialized. Run `teamai init` first.     exit 1, stack trace
7e84b6a  pull     ✖ Invalid user config at …  ✖ Invalid local config: …         nothing fetched
2d0bb35  status   ⚠ Legacy role migration skipped: Invalid roles manifest: …
                  ⚠   [agents] could not scan: Invalid roles manifest: …         exit 0, rest of the report printed
2d0bb35  pull     ⚠ Legacy role migration skipped: …  ✔ Team repo: already up to date
                  ✖ Invalid roles manifest: …  ⚠   ✖ Skills to deliver can be resolved
manifest fixed and pushed
2d0bb35  pull     ✔ Team repo: 2 file(s) changed, no manifest error; the next pull is clean

(claude is installed also fails in that run, because the temporary HOME has no ~/.claude.) It predates 0f80dcc and 2965e4d, which change only the (none) line and role-scoped entries, neither of which it has. The same member in project scope, against the github fixture, 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.yaml declares public-mcp (unscoped) and hai-mcp (roles: [hai]), env/env.yaml declares PUBLIC_VAR and HAI_SECRET (roles: [hai]), and roles.yaml is broken as above. 2d0bb35 against the final build, in one temporary HOME (with ~/.claude present, so Claude receives MCP servers):

2d0bb35  pull ×2    ⚠ roles: manifest/roles.yaml could not be read, so the "roles:" ids in mcp.yaml cannot be checked. …
                    ℹ MCP: 2 change(s) across 2 server(s)        ~/.claude.json: hai-mcp public-mcp
final    pull       ⚠ Legacy role migration skipped: …
                    ℹ MCP: 1 change(s) across 1 server(s)        ~/.claude.json: public-mcp   (hai-mcp withdrawn)
roles.yaml fixed, hai kept
final    pull #1    ✔ Synced 1 of 2 env variable(s)              (role still unresolved when this run loaded the config)
                    ℹ Migrated legacy teamai config to default role profile: hai
final    pull #2    ✔ Synced 2 env variable(s)  ℹ MCP: 1 change(s)   ~/.claude.json: hai-mcp public-mcp
                    env.sh: PUBLIC_VAR HAI_SECRET   config.yaml: primaryRole: hai

repo.localPath: ~/.teamai/projects/<hash>/team-repo in the Claude cell's config.yaml:

before:  ✖ [project] Pull failed: Cannot use simple-git on a directory that does not exist
after:   ✔ Team repo: already up to date  ✔ Synced 3 skills  ✔ Synced 2 agents  ✔ Synced 1 learnings   exit 0

Single-repo init against a broken manifest

Self-mode init swallowed 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 generic git remote:

A. no roles manifest              → exit 0, primaryRole unset          (a repo without roles)
B. skills: ['../../evil']         → ✖ Invalid roles manifest: roles.0.resources.skills.0: …   exit 1, nothing written
D. empty roles.yaml               → ✖ Invalid roles manifest: …/roles.yaml is empty. Delete it, or give it
                                     a version and a roles list.                               exit 1
C. valid manifest, --role         → exit 0, primaryRole: frontend

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's Pass --role <id> error. Both init paths, single-repo and normal, now read the same. Covered by two cases in init.test.ts alongside the real-CLI run above.

The role-id guards have no fixture path — a role id in config.yaml is whatever init wrote — so they are covered by unit tests: scanLocalForPush with primaryRole: '../../outside' and no roles.yaml, push --all in the same state, and push --all --silent with primaryRole: 'CON' against a valid manifest, each fail with Invalid role id used as a skills namespace "…" and push nothing.

Related Issues

Split from #700 (for #668).

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/projects.ts:28 rejects more than unsafe path segments. The ASCII-only regex also rejects previously working single-directory names such as 研发, team@frontend, or my team, despite the PR claiming only /, \, and .. become invalid. Validate separators and the exact ./.. segments instead, or document this as a breaking migration.
  • [P1 blocking] The PR Test Plan does not satisfy the required real-CLI matrix. It covers only Claude with the local git provider and explicitly omits Codex, CodeBuddy, OpenCode, GitLab, and GitHub. Add actual end-to-end records for all required agents and providers.
  • [P1 blocking] The manifest behavior changes without updating affected bilingual documentation. docs/usage-guide.md:229, docs/usage-guide.zh-CN.md:214, and the role-management sections describe these namespace fields but do not document the new restrictions; relevant design docs are likewise unchanged. The repository rules require all affected documentation variants to remain synchronized.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
…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.
@SaulMoro
SaulMoro force-pushed the fix/manifest-namespace-segments branch from ebe0af5 to 2048a89 Compare September 22, 2026 10:42
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

All three findings are addressed, and the branch is rebased onto current origin/main.

P1 — the ASCII allowlist rejected more than traversal

Right, and the PR body was wrong about it. SAFE_ID (^[A-Za-z0-9._-]+$) is the correct rule for a project id, which is also typed on the command line and split on commas (teamai projects set a,b), but it is far too narrow for a namespace, which is only ever a directory name. A team whose skills live under a non-ASCII directory would have stopped parsing although nothing about that directory is unsafe.

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
namespace before this PR first cut now
hai-inference parses parses parses
研发 parses rejected parses
team frontend, team@frontend parses rejected parses
../../evil, a/b, x\y parses rejected rejected
., .. parses rejected rejected
C:evil parses rejected rejected

: is refused with the separators rather than as a spelling preference: on Windows path.resolve(base, 'C:evil') is drive-relative and lands outside base, which is the same escape ../evil performs.

Both rules now live in src/manifest-schema.ts — what the two manifests genuinely share — so roles.ts no longer reaches into projects.ts for a schema.

Found while running the matrix: the error was unreadable

The Test Plan in the PR body claimed a tidy ZodError: resource namespace must be … line. The real CLI did not print that. ProjectsManifestSchema.parse() threw a raw ZodError that escaped to the top level, so an admin saw a dumped validation object and a Node stack. Every hand-written check beside it (duplicate project id, unknown resource type) already threw a sentence; the schema failure now does too:

$ teamai pull --force
✖ [project] Invalid projects manifest: projects.0.resources.skills.1: resource namespace must be
  a single path segment (no '/', '\', ':' or control characters, and not '.' or '..')
⚠ Pull finished, but 1 check(s) failed:
⚠   ✖ Skills to deliver can be resolved

$ teamai roles list
✖ Invalid roles manifest: roles.0.resources.skills.0: resource namespace must be a single path
  segment (no '/', '\', ':' or control characters, and not '.' or '..')

P1 — bilingual documentation

The namespace rule is now stated wherever either manifest is documented, in both languages:

  • docs/usage-guide.md — projects manifest section, and the roles.yaml example in the agents section
  • docs/usage-guide.zh-CN.md — the same two places
  • docs/designs/multi-project-management.md — beside the resources: example

grep -rl "resources:" docs README*.md returns exactly those three files, so nothing else describes these fields.

P1 — real-CLI matrix

Throwaway fixture team repo, three namespaces (common via role frontend, hai-inference and 研发 via project hai-inference), each carrying a skill, plus agents under common and 研发 and a project-private learning. Every cell is a fresh teamai init in its own project-scope directory followed by teamai pull --force.

agent git gitlab github
Claude pass pass pass
Codex pass pass pass
CodeBuddy pass pass pass
OpenCode pass pass pass

Delivered per cell, from all three namespaces including the non-ASCII one:

✔ [project] Synced 3 skills (3 new, 0 updated)
    new: common-guide, inference-guide, rnd-guide
✔ [project] Synced 2 agents
✔ Synced 1 learnings

.claude/skills/{common-guide,inference-guide,rnd-guide}/SKILL.md
.claude/agents/{common-reviewer,rnd-reviewer}.md

teamai projects list prints the namespace unchanged:

  hai-inference — HAI Inference
    skills:    hai-inference, 研发

Provider-specific paths (pull itself is plain git; push is what enters providers/<name>):

  • githubteamai push --all pushed the branch and opened a PR on the fixture repo.
  • git — pushed the branch and reported Automatic pull/merge request creation is not supported for generic Git hosts, exit 1, as documented.
  • gitlab — exercised the same way.

Rejection path, pushed to the fixture remote so the clone really receives it:

  • projects.yaml with skills: [hai-inference, '../../evil']pull fails the "Skills to deliver can be resolved" check with the message above and syncs nothing from that manifest.
  • roles.yaml with skills: ['evil/nested']roles list reports the entry, pull warns Could not load roles manifest. Skipping role-based filtering. and delivers project namespaces only (2 skills instead of 3).
  • Restoring both manifests → pull exits 0 and syncs 3 skills, 2 agents, 1 learning again.

One observation unrelated to this change: for codex and opencode in project scope, pull reports Synced 2 agents but no agent file appears under .codex/ or .opencode/ (skills land normally). Same on main; worth its own issue.

Checks

npx tsc --noEmit      clean
npm run build         ok
npx vitest run        3773 passed, 1 skipped, 0 failed   (268 files)

Follow-up, not in this PR

projectsList has no try/catch around loadProjectsManifest (src/projects-cmd.ts:43, unchanged here), so any manifest error — duplicate id, unknown resource type, and now a bad namespace — exits teamai projects list with a Node stack trace rather than the one-line error. roles-cmd.ts already wraps each load. The fix is the same three lines at each of the three call sites in projects-cmd.ts; happy to do it here if you prefer it not to wait.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Complete the required real-CLI test matrix — PR description, Test Plan. The repository requires E2E verification for Claude, Codex, CodeBuddy, and OpenCode across git, gitlab, and github. The PR records only Claude with git and explicitly says GitLab/GitHub were not exercised, so the description lacks the required E2E evidence.
  • [P2 non-blocking] Reject all characters described as controls — src/manifest-schema.ts:16. The regex rejects only U+0000–U+001F, while the error and documentation claim all control characters are rejected. U+007F and U+0080–U+009F still pass. Extend the range and add a test, or narrow the documented guarantee.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
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.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Complete the required real-CLI test matrix. The PR description records only the local git provider with Claude and explicitly says gitlab and github were not exercised. The repository requires end-to-end verification for Claude, Codex, CodeBuddy, and OpenCode across git, gitlab, and github. Add the missing results before merge.

@SaulMoro
SaulMoro marked this pull request as draft September 22, 2026 11:01
@SaulMoro
SaulMoro marked this pull request as ready for review September 22, 2026 11:01
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Windows traversal bypass remainssrc/manifest-schema.ts:23 rejects only exact ./.., but accepts values such as ".. " and ".. .". Win32 normalizes trailing spaces and periods in path components, potentially turning these into .. when used by filesystem operations and escaping the namespace directory. Reject trailing spaces/periods on Windows-compatible manifests, or validate the segment after Win32 normalization. Add regression tests for these variants.

The PR description includes a sufficient test plan and detailed real-CLI end-to-end matrix.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
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.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/projects.ts:36 introduces an unrelated compatibility regression: project id ... passed the base validator but now fails ONLY_DOTS. On POSIX this was a valid, working directory name, so an existing manifest can stop parsing despite the PR claiming project IDs retain their previous ASCII rule and classifying the change as non-breaking. Preserve the old exact ./.. check, or explicitly scope and document this breaking project-id change.
  • [P2 non-blocking] src/roles.ts:20 still accepts unsafe values such as learnings: ['../evil'], while docs/usage-guide.md:1452 says every namespace under role resources: follows the new rule. Since legacy role learnings are ignored, either validate them consistently or qualify the documentation as applying only to active resource fields.

The PR description includes a sufficient test plan and detailed real-CLI end-to-end record.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
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.
@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] src/projects.ts:33 still accepts the project ID ..., while the new documentation states that project IDs reject any name made only of dots and spaces (docs/usage-guide.md:244, docs/designs/multi-project-management.md:84). The added test explicitly preserves the contradictory behavior. Either scope the documentation and “same traversal guard” comment to resource namespaces, or apply the dot-only rule to project IDs.
  • The PR description includes a sufficient test plan and detailed real-CLI end-to-end matrix; no testing-description blocker.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
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.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/manifest-schema.ts:25 only rejects names consisting entirely of dots/spaces, but Windows strips trailing dots and spaces from every component. Thus frontend., frontend , or frontend.. pass validation yet alias frontend on Windows. Since pull directly joins these namespaces into resource paths, one namespace can resolve to another namespace’s directory, breaking isolation. Reject any namespace ending in . or space, and add corresponding tests.

The PR description includes a detailed test plan and real-CLI end-to-end matrix, so testing documentation is sufficient.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
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.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Invalid role namespaces disable filtering instead of stopping the pullsrc/roles.ts:13. The new schema makes loadRolesManifest() reject unsafe namespaces, but resolveResourceNamespaces() catches that error and falls back to an unfiltered sync when no project filter exists. Thus skills: ['../../evil'] causes every role-scoped resource to be delivered; the PR’s own e2e record confirms this behavior. Manifest validation failures must fail the pull or retain a restrictive filter, not broaden delivery.

The PR description includes a detailed test plan and real-CLI e2e matrix, so testing documentation is sufficient.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/roles.ts:114 classifies an empty or unreadable roles.yaml as missing. readFileSafe() returns null for every read failure, and !content also matches an empty file. resolveResourceNamespaces() then permits unfiltered delivery, defeating the new fail-closed behavior. Only an actual ENOENT should produce RolesManifestMissingError; empty/unreadable files must remain errors.
  • [P1 blocking] src/manifest-schema.ts:30 still accepts Windows reserved device names such as CON, NUL, AUX, COM1, and variants such as CON.txt. These do not safely name directories on Windows and can fail or target devices. Reject reserved device basenames case-insensitively.
  • [P1 blocking] src/resource-namespaces.ts:48 makes malformed role manifests abort the scope, but the PR’s claimed real-CLI record says the same malformed manifest warns, skips filtering, and syncs resources. The submitted e2e evidence therefore describes different code and does not validate this behavior. Re-run and document the final behavior before merge.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
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.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/bootstrap.ts:168 and src/resources/skills.ts:266 still catch every loadRolesManifest() failure as “manifest absent.” A malformed/unreadable manifest can therefore leave bootstrap without a role—causing later unfiltered sync—or let push fall back to role-ID namespaces. Update all fallback callers to catch only RolesManifestMissingError; otherwise the intended fail-closed behavior is incomplete.
  • [P1 blocking] src/manifest-schema.ts:35 incorrectly treats COM0 and LPT0 as Windows device names. Windows reserves COM1COM9 and LPT1LPT9, so this rejects previously valid namespaces without a safety justification. Use [1-9] and update both usage guides.

The PR description includes a detailed test plan and real-CLI matrix, so no testing-description finding.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
…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.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/resource-namespaces.ts:33 returns null before loading roles.yaml when a project-scoped config has no primaryRole or active project. An existing malformed roles.yaml therefore still produces an unfiltered sync—the exact fail-open behavior this PR intends to prevent. Load/validate the roles manifest before this early return, while preserving the missing-file fallback.
  • [P1 blocking] src/manifest-schema.ts:80 treats every ENOENT from readFile as an absent manifest. A committed dangling symlink such as manifest/roles.yaml -> missing.yaml also returns ENOENT, so it is treated as “no roles manifest” and permits unfiltered delivery. Verify the path itself is absent (for example with lstat) before returning null.
  • [P2 non-blocking] src/manifest-schema.ts:38 does not reject Windows’ reserved superscript device aliases such as COM¹, COM², COM³, LPT¹, LPT², and LPT³. These are treated as device names by Windows just like their ASCII-digit equivalents, so the new cross-platform namespace guarantee remains incomplete.

The PR description includes a detailed test plan and real-CLI end-to-end matrix, so no testing-description finding.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
…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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/init.ts:871 still catches every loadRolesManifest failure and continues self-mode initialization without primaryRole. It then reconciles hooks at src/init.ts:925; role-less configuration matches every role, so a malformed manifest can install role-restricted hooks. Only RolesManifestMissingError should be swallowed, as in the updated callers.
  • [P2 non-blocking] src/manifest-schema.ts:86 detects only a dangling final-file symlink. If manifest/ itself is a dangling symlink, both readFile and lstat(manifestPath) return ENOENT, so the manifest is incorrectly treated as absent and filtering may fail open. Check the parent/path components as well.

Testing

  • The PR description includes a sufficient test plan and real-CLI end-to-end matrix.
  • The tests do not cover malformed roles.yaml during self-mode init or a dangling manifest/ directory symlink.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 22, 2026
…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.
@github-actions

Copy link
Copy Markdown

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.
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (a52374a) and both findings from the last review are addressed. Head is now 2d0bb35.

Rebase

Only CHANGELOG.md (this PR's entry, once per commit that edits it) and the roles.js import in src/init.ts conflicted; git range-diff shows every other patch unchanged. #713 makes the role prompt without a terminal throw … Pass --role <id>. That is a plain error, not NoRoleSelectedError, so init still stops on it, as it did before #713, now with the clearer message.

P1: toLowerCase() is not case folding

Right. ΟΔΟΣ lowercases to οδος (final sigma), not οδοσ, and ſ has no lowercase change at all, while case-insensitive filesystems give each pair one directory. JavaScript has no case-fold primitive, so the key now upper- then lowercases each code point on its own:

- key = namespace.normalize('NFC').toLowerCase()
+ key = Array.from(namespace.normalize('NFC'), ch => ch.toUpperCase().toLowerCase()).join('').normalize('NFC')

Doing it per code point folds σ/ς and s/ſ and keeps clear of the final-sigma rule, which only applies after a cased letter. It errs toward joining (ß/ss, ı/i), which can only reject a pair, never let an alias through. Tests: σ/ς and ſ/s within roles.yaml, and ΟΔΟΣ/οδοσ role vs project. Real CLI:

✖ [project] Invalid manifests (roles.yaml with projects.yaml): skills namespaces "ΟΔΟΣ" (role frontend)
  and "οδοσ" (project hai-inference) differ only by case or Unicode normalization …

P2: a broken manifest reported as "not initialized"

Right, and it was worse than the message. loadLocalConfig is how every command starts, pull included, so the member could not fetch the corrected manifest either. The config has to stay loadable, but on its own that would widen delivery. resolveResourceNamespaces returned before reading roles.yaml for a member with no role, no project and no projects.yaml, which is exactly the member the hai migration exists for. So the fix has two parts:

 src/config.ts               migrateLegacyRoleConfig
-  broken manifest → rethrow → loadLocalConfig → null → "teamai is not initialized"
+  broken manifest → warn "Legacy role migration skipped: …", return the config unmigrated
 src/resource-namespaces.ts
-  role-less, no project, no projects.yaml → return null before roles.yaml is read
+  roles.yaml read for every member first: absent → null as before, broken → fail the pull

Running it showed a third gap. teamai status let the manifest error escape from scanLocalForPush and died with a stack trace halfway through its report. It now prints [agents] could not scan: <error> and lists the rest.

User scope, legacy role-less config, broken roles.yaml with a hai role, old head against new:

7e84b6a  status   ✖ Invalid local config: …  Error: teamai is not initialized.   exit 1
7e84b6a  pull     ✖ Invalid user config at …                                     nothing fetched
2d0bb35  status   ⚠ Legacy role migration skipped: …  ⚠ [agents] could not scan: …   exit 0
2d0bb35  pull     ✖ Invalid roles manifest: …  ⚠ ✖ Skills to deliver can be resolved   no team skill delivered
fix pushed
2d0bb35  pull     ✔ Team repo: 2 file(s) changed, no manifest error

Checks, final head 2d0bb35

npx tsc --noEmit                              clean
npx vitest run                                3938 passed, 1 skipped   (276 files)
npx vitest run --config vitest.e2e.config.ts  41 files passed, 3 skipped

Real-CLI matrix, Claude / Codex / CodeBuddy / OpenCode × git / gitlab / github: all 12 cells pass. Details are in the PR body.

@SaulMoro
SaulMoro force-pushed the fix/manifest-namespace-segments branch from 7e84b6a to 2d0bb35 Compare September 23, 2026 05:57
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/config.ts:41 keeps a malformed-manifest legacy config role-less, allowing role-scoped hooks and MCP servers to be applied to everyone. pullForScope() rejects the broken manifest, but the later hook/MCP reconciliation still runs; warnUnknownMembershipIds() catches the manifest error, and resolveMembership() treats the missing role as an unrestricted match. Preserve a fail-closed role state or skip these reconcilers when manifest validation fails.
  • [P2 non-blocking] src/status.ts:148 prints (none) when every successful scan found nothing even if other resource types failed to scan. For example, a malformed manifest can produce warnings followed by “Local resources not yet pushed: (none),” falsely implying a complete clean result. Track scan failures and suppress or qualify the (none) message.

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.
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Both findings are addressed. Head is now 2965e4d.

P1: an unmigrated legacy config matched every role-scoped entry

Right. Keeping the config loadable left it plainly role-less, and resolveMembership reads role-less as "every role". The pull refused the manifest for skills, but the hook, MCP and env reconcilers still ran for a member the manifest would have made hai. Reproduced in the real CLI, with hai-mcp scoped roles: [hai] next to an unscoped public-mcp:

2d0bb35  pull   ℹ MCP: 2 change(s) across 2 server(s)     ~/.claude.json: hai-mcp public-mcp
2965e4d  pull   ℹ MCP: 1 change(s) across 1 server(s)     ~/.claude.json: public-mcp      (hai-mcp withdrawn)
roles.yaml fixed
2965e4d  pull   ℹ Migrated legacy teamai config to default role profile: hai
2965e4d  pull   ✔ Synced 2 env variable(s)                ~/.claude.json: hai-mcp public-mcp

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

resolveMembership is the single path hooks, MCP servers and env variables take, so all three follow. The next load decides the role again. Tests: loadLocalConfig with a broken manifest yields a membership that does not match roles: [hai] but does match an unscoped entry; saveLocalConfig does not persist the field; a role-less member with a valid manifest still matches everything.

P2: "(none)" after a failed scan

Right. When nothing else is pending but a type could not be scanned, status now prints (none in the types that could be scanned).

Checks, final head 2965e4d

npx tsc --noEmit                              clean
npx vitest run                                3942 passed, 1 skipped   (276 files)
npx vitest run --config vitest.e2e.config.ts  41 files passed, 3 skipped

Real-CLI matrix, Claude / Codex / CodeBuddy / OpenCode × git / gitlab / github: all 12 cells pass on a build of 25d2298, which differs from 2965e4d only in CHANGELOG.md. The PR body has the details.

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.
@jeff-r2026

Copy link
Copy Markdown
Collaborator

Thanks for all the iterations, @SaulMoro — solid work. main moved again and it now conflicts in 8 files, incl. src/push.ts / src/roles.ts. Could you rebase once more and resolve? The last Codex findings look already fixed in 2965e4d, so it should go green on re-run.

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

@jeff-r2026 Brought up to date with main (178eca7), with all 8 conflicts resolved. Head is now f3f8dcd and the PR is mergeable again.

This time it is a merge commit rather than a rebase. #698 rewrote the same push.ts / roles.ts hunks that many of this PR's 25 commits touch, so a rebase would have meant resolving them again commit by commit. A squash merge gives the same result either way; say so if you would rather have it rebased.

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 namespaceCandidates / resolveNamespaceForNew. One behavior #698 documented changes as a result: a roles.yaml that cannot be read or parsed now stops push at the scan with exit 2, even with --role. The updated description has the details under "Merge with main", along with the before/after and the real-CLI matrix: 98/98 across 4 agents × 3 providers. CI is running on f3f8dcd.

@jeff-r2026
jeff-r2026 merged commit 95cea46 into Tencent:main Sep 23, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants