@ainova-systems/intelligence 0.11.0-rc.6 → 0.11.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +2 -2
  2. package/cli/commands/add.sh +8 -7
  3. package/cli/commands/install.sh +8 -1
  4. package/cli/commands/migrate.sh +49 -19
  5. package/cli/commands/registry.sh +1 -0
  6. package/cli/commands/sync.sh +1 -1
  7. package/cli/commands/update.sh +12 -3
  8. package/cli/engine-package.yaml +2 -2
  9. package/cli/intelligence +27 -15
  10. package/cli/lib/cli-common.sh +34 -7
  11. package/cli/lib/lockfile.sh +14 -8
  12. package/cli/lib/manifest.sh +20 -1
  13. package/cli/lib/registry.sh +25 -11
  14. package/cli/lib/semver.sh +4 -2
  15. package/engine/ENGINE_SHA +1 -0
  16. package/engine/{scripts/adapters → adapters}/agents.sh +10 -15
  17. package/engine/{scripts/lib → lib}/common.sh +15 -519
  18. package/engine/lib/contract.sh +120 -0
  19. package/engine/sync.sh +233 -0
  20. package/package.json +6 -5
  21. package/engine/INIT.md +0 -500
  22. package/engine/docs/CLI.md +0 -90
  23. package/engine/scripts/ENGINE_SHA +0 -1
  24. package/engine/scripts/lib/layout.sh +0 -51
  25. package/engine/scripts/lib/migrations.sh +0 -708
  26. package/engine/scripts/sync.sh +0 -311
  27. package/engine/scripts/update.sh +0 -237
  28. /package/engine/{scripts/VERSION → VERSION} +0 -0
  29. /package/engine/{scripts/adapters → adapters}/_template.sh +0 -0
  30. /package/engine/{scripts/adapters → adapters}/claude.sh +0 -0
  31. /package/engine/{scripts/adapters → adapters}/codex.sh +0 -0
  32. /package/engine/{scripts/adapters → adapters}/copilot.sh +0 -0
  33. /package/engine/{scripts/adapters → adapters}/cursor.sh +0 -0
  34. /package/engine/{scripts/adapters → adapters}/opencode.sh +0 -0
  35. /package/engine/{scripts/adapters → adapters}/pi.sh +0 -0
  36. /package/{engine → packages/sync}/agents/intelligence-architect.md +0 -0
  37. /package/{engine → packages/sync}/agents/intelligence-operator.md +0 -0
  38. /package/{engine → packages/sync}/docs/ADAPTERS.md +0 -0
  39. /package/{engine → packages/sync}/docs/CONVENTIONS.md +0 -0
  40. /package/{engine → packages/sync}/rules/intelligence-authoring.md +0 -0
  41. /package/{engine → packages/sync}/skills/intelligence-add-agent/SKILL.md +0 -0
  42. /package/{engine → packages/sync}/skills/intelligence-add-rule/SKILL.md +0 -0
  43. /package/{engine → packages/sync}/skills/intelligence-add-skill/SKILL.md +0 -0
  44. /package/{engine → packages/sync}/skills/intelligence-extract-skill/SKILL.md +0 -0
  45. /package/{engine → packages/sync}/skills/intelligence-install-adapter/SKILL.md +0 -0
  46. /package/{engine → packages/sync}/skills/intelligence-learn-from-context/SKILL.md +0 -0
  47. /package/{engine → packages/sync}/skills/intelligence-review-skills/SKILL.md +0 -0
  48. /package/{engine → packages/sync}/skills/intelligence-sync/SKILL.md +0 -0
  49. /package/{engine → packages/sync}/skills/intelligence-uninstall-adapter/SKILL.md +0 -0
  50. /package/{engine → packages/sync}/skills/intelligence-update/SKILL.md +0 -0
package/README.md CHANGED
@@ -38,9 +38,9 @@ intelligence install
38
38
  | `intelligence update [pkg]` | Re-resolve version ranges, rewrite the lock |
39
39
  | `intelligence sync [target]` | Render intelligence to every enabled tool |
40
40
  | `intelligence list` / `doctor` / `status` | Inspect and verify |
41
- | `intelligence registry add @scope <url>` | Bind a scope to a private registry index |
41
+ | `intelligence registry add <repo-url>` | Trust a registry (a git repo with an `index.yaml`) |
42
42
  | `intelligence migrate` | Convert a vendored intelligence-sync setup to the CLI |
43
43
 
44
- Any git repo with `rules/`, `agents/` or `skills/` at its root is already an Intelligence Package: `intelligence add @your-org/your-repo` resolves it straight from GitHub — versions come from its `x.y.z` git tags. A monorepo of packages needs one line in a registry index.
44
+ Any git repo with `rules/`, `agents/` or `skills/` at its root is already an Intelligence Package: install it from an explicit source — `intelligence add github:your-org/your-repo` — with versions from its `x.y.z` git tags. Names (`@scope/name`) resolve only through registries this project explicitly trusts; a monorepo of packages needs one line in such an index.
45
45
 
46
46
  Requires `git` (and on Windows the bash it ships with — Git for Windows). Engine and docs: [ainova-systems/intelligence-sync](https://github.com/ainova-systems/intelligence-sync).
@@ -2,9 +2,9 @@
2
2
  # intelligence add <spec> [--name @scope/name] [--no-sync]
3
3
  #
4
4
  # Specs:
5
- # @scope/name[@range] resolved via registries / index / convention
6
- # github:org/repo[#path] direct from GitHub, no registry involved
7
- # git+<url>[@ref][#path] direct from any git host
5
+ # @scope/name[@range] resolved ONLY through the manifest's trusted registries
6
+ # github:org/repo[#path] explicit source: direct from GitHub, no registry involved
7
+ # git+<url>[@ref][#path] explicit source: direct from any git host
8
8
  set -euo pipefail
9
9
  source "$CLI_DIR/lib/cli-common.sh"
10
10
 
@@ -43,14 +43,15 @@ case "$spec" in
43
43
  rest="${spec#git+}"
44
44
  case "$rest" in *#*) path="${rest#*#}"; rest="${rest%%#*}" ;; esac
45
45
  # ref = the part after the last `@` in the post-scheme URL, unless it
46
- # contains `/` (then it is a user@host, not a ref) — the engine's own
47
- # git+ grammar.
46
+ # contains `/` or `:` — either marks a user@host part (scp-like
47
+ # `git@host:repo.git` included), not a ref. A branch name containing
48
+ # `/` cannot be expressed here; pin it via `ref:` in the manifest.
48
49
  base="${rest#*://}"
49
50
  case "$base" in
50
51
  *@*)
51
52
  tail="${base##*@}"
52
53
  case "$tail" in
53
- */*) ;;
54
+ */*|*:*) ;;
54
55
  *) ref="$tail"; rest="${rest%@"$tail"}" ;;
55
56
  esac
56
57
  ;;
@@ -93,7 +94,7 @@ fi
93
94
 
94
95
  # The source must exist before anything else is attempted — never die
95
96
  # mid-pipeline with no message.
96
- if ! GIT_TERMINAL_PROMPT=0 git ls-remote "$url" >/dev/null 2>&1; then
97
+ if ! GIT_TERMINAL_PROMPT=0 git ls-remote -- "$url" >/dev/null 2>&1; then
97
98
  echo "ERROR: no reachable repository at $url" >&2
98
99
  echo " Declared by ${RES_VIA:-the spec} — check the URL and your git access." >&2
99
100
  exit 1
@@ -63,6 +63,7 @@ fi
63
63
  missing=""
64
64
  while IFS= read -r name; do
65
65
  [ -n "$name" ] || continue
66
+ assert_valid_pkg_name "$name"
66
67
  if [ ! -f "$lock" ] || [ -z "$(qmap_field "$lock" "packages" "$name" "url")" ]; then
67
68
  missing="$missing $name"
68
69
  fi
@@ -101,8 +102,14 @@ fi
101
102
  if [ -f "$lock" ]; then
102
103
  while IFS="$LOCK_SEP" read -r name requested url path resolved sha; do
103
104
  [ -n "$name" ] || continue
105
+ # The lock arrives with a cloned repo — its keys are untrusted input
106
+ # on their way into rm -rf paths, and its urls into git argv.
107
+ assert_valid_pkg_name "$name"
104
108
  rel=".intelligence/packages/$name"
105
- if [ -d "$IP_ROOT/$rel" ] && [ "$force" -eq 0 ]; then
109
+ # --frozen never trusts what is already on disk: a pre-existing
110
+ # (possibly substituted) store dir would otherwise be synced with no
111
+ # sha verification at all.
112
+ if [ -d "$IP_ROOT/$rel" ] && [ "$force" -eq 0 ] && [ "$frozen" -eq 0 ]; then
106
113
  continue
107
114
  fi
108
115
  # Fetch into staging and commit to the store only after the integrity
@@ -3,7 +3,7 @@
3
3
  # to the CLI setup: root intelligence.yaml, .intelligence/ store,
4
4
  # intelligence.lock, no vendored engine.
5
5
  #
6
- # Transactional in the migrations.sh spirit: STAGE everything next to the
6
+ # Transactional: STAGE everything next to the
7
7
  # project, VERIFY the staged state, then COMMIT in an order where every
8
8
  # destructive step happens only after a real sync of the new state reported
9
9
  # IS_STATUS=ok. Any earlier failure leaves the project untouched.
@@ -44,6 +44,12 @@ stamp="$(read_engine_stamp "$config")"
44
44
  eng="$(bundled_engine_version)"
45
45
  [ -n "$stamp" ] && _ver_gt "$stamp" "$eng" && die "project schema $stamp is newer than this CLI's engine $eng — update the CLI first"
46
46
 
47
+ # The v1 `packs:` block is the one v1 shape migrate still has to read, and the
48
+ # engine no longer knows it — the reader lives here, with its only consumer.
49
+ get_pack_field() {
50
+ get_nested_yaml_value "$1" "packs" "$2" "$3"
51
+ }
52
+
47
53
  # ---- Stage ----------------------------------------------------------------
48
54
  stage="$root/.intelligence-migrate-stage.$$"
49
55
  [ "$dry_run" -eq 1 ] && stage="$(mktemp -d -t intelligence-migrate-XXXXXX 2>/dev/null || mktemp -d)"
@@ -51,22 +57,18 @@ cleanup_stage() { rm -rf "$stage"; }
51
57
  trap cleanup_stage EXIT INT TERM
52
58
  mkdir -p "$stage/.intelligence/packages"
53
59
 
54
- # A schema gap is closed by the engine's own proven chain before conversion.
55
- # Dry-run must preview the SAME pipeline, so it runs the chain too — against
56
- # a staged copy of the umbrella, leaving the project untouched.
57
- if [ -z "$stamp" ] || _ver_gt "$eng" "$stamp"; then
58
- if [ "$dry_run" -eq 1 ]; then
59
- echo "== applying the engine migration chain ($stamp -> $eng) to a staged copy =="
60
- cp -R "$umbrella" "$stage/umbrella"
61
- run_migrations "$stage/umbrella" "$(basename "$module_dir")" "" > "$stage/chain.log" 2>&1 \
62
- || { tail -5 "$stage/chain.log" >&2; die "engine migration chain failed in the staged copy — a real migrate would fail the same way"; }
63
- stamp_version "$stage/umbrella/config.yaml" "$eng"
64
- config="$stage/umbrella/config.yaml"
65
- else
66
- echo "== applying the engine migration chain ($stamp -> $eng) =="
67
- run_migrations "$umbrella" "$(basename "$module_dir")" "" || die "engine migration chain failed (IS_STATUS above) — nothing converted"
68
- stamp_version "$config" "$eng"
69
- fi
60
+ # v2 carries no v1 migration chain: v1 is archived, and the archived engine is
61
+ # what knows how to walk an old project forward. So migrate accepts only a
62
+ # project already at the final v1 schema and names the exact remedy otherwise —
63
+ # a remedy that keeps working indefinitely, because the archive stays at the
64
+ # URL the project's own update.sh already clones.
65
+ V1_FINAL_SCHEMA="0.10.0"
66
+ if [ -z "$stamp" ] || _ver_gt "$V1_FINAL_SCHEMA" "$stamp"; then
67
+ echo "ERROR: project schema is ${stamp:-absent (pre-0.3.1)}, older than the final v1 schema $V1_FINAL_SCHEMA." >&2
68
+ echo " Bring it forward with its own engine first — that still works:" >&2
69
+ echo " bash $module_rel/scripts/update.sh --yes" >&2
70
+ echo " then re-run: intelligence migrate" >&2
71
+ exit 1
70
72
  fi
71
73
 
72
74
  echo "== staging =="
@@ -78,6 +80,7 @@ sync_pkg_sha="$(fetch_package "$SYNC_PKG_URL" "v$eng" "$SYNC_PKG_PATH" "$stage/$
78
80
  # per-pack source facts. Recorded as rows for the config rewrite and the lock.
79
81
  pack_rows="$stage/packs.rows"
80
82
  : > "$pack_rows"
83
+ seen_names=" "
81
84
  while IFS= read -r pack; do
82
85
  [ -n "$pack" ] || continue
83
86
  url="$(get_pack_field "$config" "$pack" "url")"
@@ -91,6 +94,12 @@ while IFS= read -r pack; do
91
94
  case "$repo" in ""|*[!A-Za-z0-9._-]*) repo="$(printf '%s' "$repo" | tr -c 'A-Za-z0-9._-' '-')" ;; esac
92
95
  name="@$org/$repo"
93
96
  assert_valid_pkg_name "$name"
97
+ # Two packs deriving the same @org/repo would overwrite one store dir and
98
+ # duplicate the lock key — fail closed, the user renames a source.
99
+ case "$seen_names" in
100
+ *" $name "*) die "packs '$pack' and another both derive package name '$name' — sources must map to distinct names" ;;
101
+ esac
102
+ seen_names="$seen_names $name "
94
103
  printf '%s\n' "$pack$LOCK_SEP$name$LOCK_SEP$url$LOCK_SEP$ref$LOCK_SEP$mirror"
95
104
  done < <(read_yaml_keys "$config" "packs") >> "$pack_rows"
96
105
 
@@ -164,8 +173,19 @@ while IFS= read -r line || [ -n "$line" ]; do
164
173
  *"- "*)
165
174
  indent="${line%%-*}"
166
175
  v="${line#*- }"
176
+ # A trailing inline comment survives the rewrite — split it
177
+ # off before touching quotes, or it would be mangled into the
178
+ # value.
179
+ trail=""
180
+ case "$v" in
181
+ *[[:space:]]"#"*)
182
+ trail=" #${v#*[[:space:]]#}"
183
+ v="${v%%[[:space:]]#*}"
184
+ ;;
185
+ esac
186
+ v="${v%"${v##*[![:space:]]}"}"
167
187
  v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
168
- echo "$indent- \"$(rewrite_source_value "$v")\""
188
+ echo "$indent- \"$(rewrite_source_value "$v")\"$trail"
169
189
  continue
170
190
  ;;
171
191
  esac
@@ -246,6 +266,8 @@ rollback() {
246
266
  fi
247
267
  echo "rolled back — the vendored setup is untouched" >&2
248
268
  }
269
+ # From the first moved file until success, an interrupt must roll back too.
270
+ trap 'rollback; exit 130' INT TERM
249
271
  mv "$stage/.intelligence" "$root/.intelligence"
250
272
  cp "$manifest_stage" "$root/intelligence.yaml"
251
273
  lock_write_from_tsv "$root/intelligence.lock" "$lock_rows"
@@ -260,13 +282,21 @@ echo "$sync_out" | grep -q '^IS_STATUS=ok' || {
260
282
  rollback
261
283
  die "sync of the migrated state did not report ok — rolled back"
262
284
  }
285
+ # Point of no return: the new state is verified. From here an interrupt must
286
+ # NOT roll back (the old setup is being removed) — only clean the stage.
287
+ trap cleanup_stage EXIT INT TERM
263
288
 
264
289
  # Only now is anything of the old setup removed — and config.yaml is kept as
265
290
  # a reviewable backup inside the store.
266
291
  mkdir -p "$root/.intelligence/backup"
267
292
  cp "$config" "$root/.intelligence/backup/config.yaml"
268
293
  while IFS="$LOCK_SEP" read -r pack name url ref mirror; do
269
- if [ -n "$mirror" ] && [ -d "$root/$mirror" ]; then
294
+ # Containment before deletion: a mirror value is config input, and rm -rf
295
+ # must never follow it out of the repository.
296
+ case "$mirror" in
297
+ ""|/*|*..*) [ -n "$mirror" ] && echo " WARN: mirror '$mirror' not repo-contained — left in place" >&2; continue ;;
298
+ esac
299
+ if [ -d "$root/$mirror" ]; then
270
300
  rm -rf "${root:?}/$mirror"
271
301
  # An emptied mirror parent (e.g. <umbrella>/external/) goes with it.
272
302
  rmdir "$(dirname "$root/$mirror")" 2>/dev/null || true
@@ -38,6 +38,7 @@ case "$sub" in
38
38
  case "$url" in
39
39
  @*) die "registries are added by URL — names carry their own scope. To install a package: intelligence add $url/<name>" ;;
40
40
  esac
41
+ assert_safe_source_url "${url#git+}"
41
42
  require_v2
42
43
  # Fail closed: a URL that cannot produce an index is not a registry,
43
44
  # and recording it would turn a typo here into a confusing failure at
@@ -26,7 +26,7 @@ case "$IP_MODE" in
26
26
  die "installed content missing on disk:$missing — run 'intelligence install'"
27
27
  fi
28
28
  export_engine_env "$IP_ROOT"
29
- exec bash "$IS_ENGINE_DIR/scripts/sync.sh" "$@"
29
+ exec bash "$IS_ENGINE_DIR/sync.sh" "$@"
30
30
  ;;
31
31
  legacy)
32
32
  # A vendored project syncs with ITS engine — its pin is the contract,
@@ -24,6 +24,8 @@ lock="$IP_ROOT/intelligence.lock"
24
24
  moved=0 found=0
25
25
  while IFS= read -r name; do
26
26
  [ -n "$name" ] || continue
27
+ # Manifest keys are untrusted input on their way into store paths.
28
+ assert_valid_pkg_name "$name"
27
29
  [ -n "$only" ] && [ "$name" != "$only" ] && continue
28
30
  # A named package that exists is "found" whatever happens next — a skip
29
31
  # message followed by "not in the manifest" would contradict itself.
@@ -60,10 +62,17 @@ while IFS= read -r name; do
60
62
  continue
61
63
  fi
62
64
  rel=".intelligence/packages/$name"
63
- # The new version may have dropped a top-level section dir: unwire the old
64
- # shape first, or a dangling store-backed source bricks the next sync.
65
+ # Fetch into staging FIRST: a failed clone must leave the current install
66
+ # fully wired and intact. Only after success is the old shape unwired
67
+ # (the new version may have dropped a section dir), the store swapped,
68
+ # and the new shape wired.
69
+ staging="$IP_ROOT/.intelligence/.staging-$$"
70
+ rm -rf "$staging"
71
+ sha="$(fetch_package "$url" "$tag" "$path" "$staging")"
65
72
  unwire_package_sources "$manifest" "$rel"
66
- sha="$(fetch_package "$url" "$tag" "$path" "$IP_ROOT/$rel")"
73
+ rm -rf "${IP_ROOT:?}/$rel"
74
+ mkdir -p "$(dirname "$IP_ROOT/$rel")"
75
+ mv "$staging" "$IP_ROOT/$rel"
67
76
  wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT"
68
77
  lock_upsert "$lock" "$name" "$range" "$url" "$path" "$tag" "$sha"
69
78
  echo " $name: ${current:-<none>} -> $tag"
@@ -9,6 +9,6 @@
9
9
  # `upgrade` moves it, and at the matching version it materializes from the
10
10
  # bundle without network.
11
11
  name: "@ainova-systems/sync"
12
- url: "https://github.com/ainova-systems/intelligence-sync.git"
13
- path: "intelligence/sync"
12
+ url: "https://github.com/ainova-systems/intelligence.git"
13
+ path: "packages/sync"
14
14
  default_registry: "https://github.com/ainova-systems/intelligence-registry.git"
package/cli/intelligence CHANGED
@@ -3,25 +3,20 @@
3
3
  #
4
4
  # Dispatcher only: each command lives in commands/<name>.sh so commands can be
5
5
  # ported to Node one at a time without touching the others. The engine is the
6
- # vendored bash engine bundled next to the CLI (npm package) or, when running
7
- # from the intelligence-sync repo itself, the repo's own module.
6
+ # bash engine shipped next to the CLI.
8
7
 
9
8
  set -euo pipefail
10
9
 
11
10
  CLI_DIR="$(cd "$(dirname "$0")" && pwd)"
12
11
  export CLI_DIR
13
12
 
14
- # Engine discovery, by role (scripts/sync.sh + scripts/VERSION), never by
15
- # guessing names beyond the two shipped layouts:
16
- # 1. npm package: <pkg>/cli/intelligence + <pkg>/engine/scripts/
17
- # 2. this repo: <repo>/cli/intelligence + <repo>/intelligence/sync/scripts/
13
+ # Engine discovery, by role (sync.sh + VERSION). The repo and the npm package
14
+ # ship one layout — <root>/{cli,engine,packages/sync} — so there is a single
15
+ # candidate rather than a per-distribution list.
18
16
  IS_ENGINE_DIR=""
19
- for _cand in "$CLI_DIR/../engine" "$CLI_DIR/../intelligence/sync"; do
20
- if [ -f "$_cand/scripts/sync.sh" ] && [ -f "$_cand/scripts/VERSION" ]; then
21
- IS_ENGINE_DIR="$(cd "$_cand" && pwd)"
22
- break
23
- fi
24
- done
17
+ if [ -f "$CLI_DIR/../engine/sync.sh" ] && [ -f "$CLI_DIR/../engine/VERSION" ]; then
18
+ IS_ENGINE_DIR="$(cd "$CLI_DIR/../engine" && pwd)"
19
+ fi
25
20
  if [ -z "$IS_ENGINE_DIR" ]; then
26
21
  echo "ERROR: bundled sync engine not found next to the CLI ($CLI_DIR)." >&2
27
22
  echo " Reinstall: npm i -g @ainova-systems/intelligence" >&2
@@ -29,10 +24,19 @@ if [ -z "$IS_ENGINE_DIR" ]; then
29
24
  fi
30
25
  export IS_ENGINE_DIR
31
26
 
27
+ # The engine's own content, shipped beside the engine as the seed for the
28
+ # @ainova-systems/sync package at exactly this CLI's version. Separate from the
29
+ # engine dir: the store must receive rules/agents/skills, never engine scripts.
30
+ IS_BUNDLED_PKG_DIR=""
31
+ if [ -d "$CLI_DIR/../packages/sync" ]; then
32
+ IS_BUNDLED_PKG_DIR="$(cd "$CLI_DIR/../packages/sync" && pwd)"
33
+ fi
34
+ export IS_BUNDLED_PKG_DIR
35
+
32
36
  cmd="${1:-}"
33
37
  case "$cmd" in
34
38
  ""|help|-h|--help)
35
- engine_ver="$(tr -d ' \t\r\n' < "$IS_ENGINE_DIR/scripts/VERSION")"
39
+ engine_ver="$(tr -d ' \t\r\n' < "$IS_ENGINE_DIR/VERSION")"
36
40
  cat <<EOF
37
41
  intelligence — build, version and distribute AI agent intelligence
38
42
  (engine $engine_ver${INTELLIGENCE_NPM_VERSION:+, package $INTELLIGENCE_NPM_VERSION})
@@ -48,7 +52,7 @@ Usage: intelligence <command> [args]
48
52
  list Installed packages with requested/locked versions
49
53
  search [term] What the registries offer, and what this project has
50
54
  sync [target] Render intelligence to every enabled tool (.claude, AGENTS.md, ...)
51
- registry <list|add|remove> Bind a scope to a registry index
55
+ registry <list|add|remove> Manage the trusted registry list (add <repo-url>)
52
56
  migrate [--dry-run] Convert a vendored (v1) setup to the CLI setup
53
57
  doctor Check manifest, lockfile, store and engine consistency
54
58
  status Project state at a glance
@@ -57,13 +61,21 @@ EOF
57
61
  exit 0
58
62
  ;;
59
63
  version|-v|--version)
60
- engine_ver="$(tr -d ' \t\r\n' < "$IS_ENGINE_DIR/scripts/VERSION")"
64
+ engine_ver="$(tr -d ' \t\r\n' < "$IS_ENGINE_DIR/VERSION")"
61
65
  echo "${INTELLIGENCE_NPM_VERSION:-$engine_ver} (engine $engine_ver)"
62
66
  exit 0
63
67
  ;;
64
68
  esac
65
69
  shift
66
70
 
71
+ # The command word builds a path — allow exactly lowercase words, so
72
+ # `intelligence ../x` can never exec a neighbouring script.
73
+ case "$cmd" in
74
+ *[!a-z-]*|-*)
75
+ echo "ERROR: unknown command '$cmd'. Run 'intelligence help'." >&2
76
+ exit 1
77
+ ;;
78
+ esac
67
79
  cmd_file="$CLI_DIR/commands/$cmd.sh"
68
80
  if [ ! -f "$cmd_file" ]; then
69
81
  echo "ERROR: unknown command '$cmd'. Run 'intelligence help'." >&2
@@ -7,18 +7,45 @@
7
7
  # CLI-owned parsing lives in lib/manifest.sh (quoted-key `packages:` /
8
8
  # `registries:` blocks, which the engine deliberately never reads).
9
9
 
10
- # Engine libraries (readers, is_status, IS_RC_*, engine_version). Sourced in
11
- # the same order sync.sh uses. CLI_DIR / IS_ENGINE_DIR come exported from the
12
- # dispatcher.
13
- source "$IS_ENGINE_DIR/scripts/lib/common.sh"
14
- source "$IS_ENGINE_DIR/scripts/lib/layout.sh"
15
- source "$IS_ENGINE_DIR/scripts/lib/migrations.sh"
10
+ # Engine libraries (readers, is_status, IS_RC_*, engine_version). CLI_DIR /
11
+ # IS_ENGINE_DIR come exported from the dispatcher.
12
+ source "$IS_ENGINE_DIR/lib/common.sh"
13
+ source "$IS_ENGINE_DIR/lib/contract.sh"
16
14
 
17
15
  die() {
18
16
  echo "ERROR: $*" >&2
19
17
  exit 1
20
18
  }
21
19
 
20
+ # --- Untrusted-input guards ----------------------------------------------
21
+ # Manifest, lock and registry indexes arrive with a cloned repo: every value
22
+ # they carry is attacker-adjacent. URLs and refs reach git argv (a leading
23
+ # `-` would be an option — `--upload-pack=<cmd>` is code execution), names
24
+ # become store paths fed to rm -rf. Guard at the choke points, loudly.
25
+
26
+ # assert_safe_source_url <url> — scheme allowlist (the engine's own list) or
27
+ # scp-like user@host:path; never option-shaped, never quote-bearing.
28
+ assert_safe_source_url() {
29
+ local url="$1"
30
+ case "$url" in
31
+ ""|-*) die "unsafe source url '$url' — option-shaped or empty" ;;
32
+ *[\"\'\ ]*) die "unsafe source url '$url' — quotes or spaces" ;;
33
+ https://*|http://*|ssh://*|git://*|file://*) ;;
34
+ *@*:*) ;;
35
+ *) die "unsafe source url '$url' — allowed: https, http, ssh, git, file, or user@host:path" ;;
36
+ esac
37
+ }
38
+
39
+ # assert_safe_ref <ref-or-empty> — tags/branches/SHAs; never option-shaped.
40
+ assert_safe_ref() {
41
+ local ref="$1"
42
+ [ -z "$ref" ] && return 0
43
+ case "$ref" in
44
+ -*) die "unsafe git ref '$ref' — option-shaped" ;;
45
+ *[\"\'\ \\]*) die "unsafe git ref '$ref'" ;;
46
+ esac
47
+ }
48
+
22
49
  source "$CLI_DIR/lib/manifest.sh"
23
50
  source "$CLI_DIR/lib/semver.sh"
24
51
  source "$CLI_DIR/lib/registry.sh"
@@ -100,7 +127,7 @@ manifest_intelligence_dir() {
100
127
  }
101
128
 
102
129
  bundled_engine_version() {
103
- tr -d ' \t\r\n' < "$IS_ENGINE_DIR/scripts/VERSION"
130
+ tr -d ' \t\r\n' < "$IS_ENGINE_DIR/VERSION"
104
131
  }
105
132
 
106
133
  # --- The sync package's manifest/lock plumbing ---------------------------
@@ -54,14 +54,18 @@ lock_write_from_tsv() {
54
54
  echo "lockfile_version: $LOCKFILE_VERSION"
55
55
  echo "engine_version: \"$(bundled_engine_version)\""
56
56
  echo "packages:"
57
+ # Values reach a double-quoted YAML scalar: a `"` or `\` in any of
58
+ # them (registry-supplied url/path included) must be escaped, or it
59
+ # would break or alias the neighbouring field.
57
60
  awk -F"$LOCK_SEP" '
61
+ function esc(s) { gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); return s }
58
62
  NF >= 6 {
59
- print " \"" $1 "\":"
60
- if ($2 != "") print " requested: \"" $2 "\""
61
- print " url: \"" $3 "\""
62
- if ($4 != "") print " path: \"" $4 "\""
63
- if ($5 != "") print " resolved: \"" $5 "\""
64
- print " sha: \"" $6 "\""
63
+ print " \"" esc($1) "\":"
64
+ if ($2 != "") print " requested: \"" esc($2) "\""
65
+ print " url: \"" esc($3) "\""
66
+ if ($4 != "") print " path: \"" esc($4) "\""
67
+ if ($5 != "") print " resolved: \"" esc($5) "\""
68
+ print " sha: \"" esc($6) "\""
65
69
  }
66
70
  ' "$tsv"
67
71
  } > "$tmp"
@@ -72,7 +76,8 @@ lock_write_from_tsv() {
72
76
  # Replace-or-append one entry, keeping the others.
73
77
  lock_upsert() {
74
78
  local lock="$1" name="$2" requested="$3" url="$4" path="$5" resolved="$6" sha="$7"
75
- local tsv="${TMPDIR:-/tmp}/intelligence-lock-$$.tsv"
79
+ local tsv
80
+ tsv="$(mktemp -t intelligence-lock-XXXXXX 2>/dev/null || mktemp)"
76
81
  {
77
82
  lock_to_tsv "$lock" | awk -F"$LOCK_SEP" -v n="$name" '$1 != n'
78
83
  printf '%s\037%s\037%s\037%s\037%s\037%s\n' \
@@ -86,7 +91,8 @@ lock_upsert() {
86
91
  lock_remove() {
87
92
  local lock="$1" name="$2"
88
93
  [ -f "$lock" ] || return 0
89
- local tsv="${TMPDIR:-/tmp}/intelligence-lock-$$.tsv"
94
+ local tsv
95
+ tsv="$(mktemp -t intelligence-lock-XXXXXX 2>/dev/null || mktemp)"
90
96
  lock_to_tsv "$lock" | awk -F"$LOCK_SEP" -v n="$name" '$1 != n' > "$tsv"
91
97
  if [ -s "$tsv" ]; then
92
98
  lock_write_from_tsv "$lock" "$tsv"
@@ -114,9 +114,19 @@ _qmap_stage() {
114
114
 
115
115
  # qmap_set <file> <block> <key> <field> <value> — upsert one field, creating
116
116
  # the block and the key as needed.
117
+ # _yq_esc <string> — escape \ and " for a double-quoted YAML scalar.
118
+ _yq_esc() {
119
+ local s="$1"
120
+ s="${s//\\/\\\\}"
121
+ printf '%s' "${s//\"/\\\"}"
122
+ }
123
+
117
124
  qmap_set() {
118
125
  local file="$1" block="$2" key="$3" field="$4" value="$5"
119
126
  [ -f "$file" ] || die "no such file: $file"
127
+ # url/path values can carry a `"`; escape before it reaches the quoted
128
+ # scalar the writer emits.
129
+ value="$(_yq_esc "$value")"
120
130
  _qmap_stage "$file" -v block="$block" -v key="$key" -v field="$field" -v value="$value" '
121
131
  function keyline() { return " \"" key "\":" }
122
132
  function fieldline() { return " " field ": \"" value "\"" }
@@ -163,6 +173,7 @@ qmap_set() {
163
173
  qmap_set_value() {
164
174
  local file="$1" block="$2" key="$3" value="$4"
165
175
  [ -f "$file" ] || die "no such file: $file"
176
+ value="$(_yq_esc "$value")"
166
177
  _qmap_stage "$file" -v block="$block" -v key="$key" -v value="$value" '
167
178
  function entry() { return " \"" key "\": \"" value "\"" }
168
179
  { sub(/\r$/, "") }
@@ -282,6 +293,14 @@ registries_remove() {
282
293
  { print }
283
294
  '
284
295
  }
296
+ # True (0) if <config> already lists <entry> under any sources section. Quoted
297
+ # and bare spellings both count - a manifest may hold either.
298
+ _mig_has_source() {
299
+ local config="$1" entry="$2"
300
+ [ -f "$config" ] || return 1
301
+ grep -Fq -- "\"$entry\"" "$config" || grep -Fq -- "- $entry" "$config"
302
+ }
303
+
285
304
 
286
305
  # sources_add_entry_first <file> <section> <entry> — idempotent insert at the
287
306
  # TOP of sources.<section> (creating sources:/section as needed). Package
@@ -313,7 +332,7 @@ sources_add_entry_first() {
313
332
  }
314
333
 
315
334
  # sources_remove_entry <file> <section> <entry> — remove `- "entry"` from
316
- # sources.<section>. Inverse of the engine-side _mig_add_source.
335
+ # sources.<section>.
317
336
  sources_remove_entry() {
318
337
  local file="$1" section="$2" entry="$3"
319
338
  [ -f "$file" ] || return 0
@@ -21,16 +21,26 @@
21
21
  # _fetch_index <registry-repo-url> — clones the registry repo (shallow) and
22
22
  # prints the path of its index.yaml; empty on failure. Cached per run+url.
23
23
  _fetch_index() {
24
- local url="$1" cache key tmp
25
- cache="${TMPDIR:-/tmp}/intelligence-cli-index-$$"
24
+ local url="$1" key tmp
25
+ assert_safe_source_url "$url"
26
+ # Per-run private cache dir under an UNPREDICTABLE mktemp path (a fixed
27
+ # /tmp/...-$$ would be trusted on cache hit and pre-creatable by others).
28
+ # No EXIT trap: _fetch_index is always called inside $( ), so a trap would
29
+ # fire in that subshell and delete the dir before the caller reads it. The
30
+ # dir is small and OS-reaped from the temp root; IS_REMOTE_CACHE-style
31
+ # persistence is exported so repeated calls in one command share it.
32
+ if [ -z "${IS_CLI_INDEX_CACHE:-}" ]; then
33
+ IS_CLI_INDEX_CACHE="$(mktemp -d -t intelligence-cli-index-XXXXXX 2>/dev/null || mktemp -d)"
34
+ export IS_CLI_INDEX_CACHE
35
+ fi
36
+ local cache="$IS_CLI_INDEX_CACHE"
26
37
  key="$(printf '%s' "$url" | cksum | awk '{print $1}')"
27
38
  if [ -f "$cache/$key/index.yaml" ]; then
28
39
  printf '%s' "$cache/$key/index.yaml"
29
40
  return 0
30
41
  fi
31
- mkdir -p "$cache"
32
42
  tmp="$cache/$key"
33
- if GIT_TERMINAL_PROMPT=0 git -c core.autocrlf=false clone --depth 1 --quiet "$url" "$tmp" 2>/dev/null \
43
+ if GIT_TERMINAL_PROMPT=0 git -c core.autocrlf=false clone --depth 1 --quiet -- "$url" "$tmp" 2>/dev/null \
34
44
  && [ -f "$tmp/index.yaml" ]; then
35
45
  printf '%s' "$tmp/index.yaml"
36
46
  fi
@@ -103,6 +113,8 @@ suggest_similar() {
103
113
  # store holds the bytes the package published.
104
114
  fetch_package() {
105
115
  local url="$1" ref="$2" subpath="$3" dest="$4"
116
+ assert_safe_source_url "$url"
117
+ assert_safe_ref "$ref"
106
118
  # Bundle seed: the engine-content package at the CLI's own version copies
107
119
  # from the npm bundle — no network, which keeps init / fresh-clone install
108
120
  # / migrate offline exactly like the staging they replace. Keyed on the
@@ -110,12 +122,14 @@ fetch_package() {
110
122
  # to the normal clone.
111
123
  if [ "$url" = "$SYNC_PKG_URL" ] && [ "$subpath" = "$SYNC_PKG_PATH" ] \
112
124
  && [ "${ref#v}" = "$(bundled_engine_version)" ]; then
125
+ [ -n "${IS_BUNDLED_PKG_DIR:-}" ] && [ -d "$IS_BUNDLED_PKG_DIR" ] \
126
+ || die "bundled engine content not found next to the CLI — reinstall @ainova-systems/intelligence"
113
127
  rm -rf "$dest"
114
128
  mkdir -p "$dest"
115
- cp -R "$IS_ENGINE_DIR/." "$dest/"
129
+ cp -R "$IS_BUNDLED_PKG_DIR/." "$dest/"
116
130
  rm -rf "$dest/.git"
117
- if [ -f "$IS_ENGINE_DIR/scripts/ENGINE_SHA" ]; then
118
- tr -d ' \t\r\n' < "$IS_ENGINE_DIR/scripts/ENGINE_SHA"
131
+ if [ -f "$IS_ENGINE_DIR/ENGINE_SHA" ]; then
132
+ tr -d ' \t\r\n' < "$IS_ENGINE_DIR/ENGINE_SHA"
119
133
  fi
120
134
  return 0
121
135
  fi
@@ -124,20 +138,20 @@ fetch_package() {
124
138
  [ -n "$ref" ] && branch_arg=(--branch "$ref")
125
139
  tmp="$(mktemp -d -t intelligence-pkg-XXXXXX 2>/dev/null || mktemp -d)"
126
140
  if ! GIT_TERMINAL_PROMPT=0 git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
127
- clone --depth 1 "${branch_arg[@]+"${branch_arg[@]}"}" --quiet "$url" "$tmp/clone" 2>/dev/null; then
141
+ clone --depth 1 "${branch_arg[@]+"${branch_arg[@]}"}" --quiet -- "$url" "$tmp/clone" 2>/dev/null; then
128
142
  # A SHA is not clonable with --branch — fall back to a full clone + checkout.
129
143
  rm -rf "$tmp/clone"
130
144
  GIT_TERMINAL_PROMPT=0 git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
131
- clone --quiet "$url" "$tmp/clone" 2>/dev/null \
145
+ clone --quiet -- "$url" "$tmp/clone" 2>/dev/null \
132
146
  || { rm -rf "$tmp"; die "cannot clone $url"; }
133
- [ -z "$ref" ] || git -C "$tmp/clone" checkout --quiet "$ref" \
147
+ [ -z "$ref" ] || git -C "$tmp/clone" checkout --quiet "$ref" -- \
134
148
  || { rm -rf "$tmp"; die "ref '$ref' not found in $url"; }
135
149
  fi
136
150
  sha="$(git -C "$tmp/clone" rev-parse HEAD)"
137
151
  src="$tmp/clone"
138
152
  if [ -n "$subpath" ]; then
139
153
  case "$subpath" in
140
- *..*) rm -rf "$tmp"; die "unsafe path '$subpath'" ;;
154
+ *..*|/*|*[\"\']*) rm -rf "$tmp"; die "unsafe path '$subpath'" ;;
141
155
  esac
142
156
  src="$tmp/clone/$subpath"
143
157
  [ -d "$src" ] || { rm -rf "$tmp"; die "path '$subpath' not found in $url${ref:+@$ref}" ; }
package/cli/lib/semver.sh CHANGED
@@ -75,10 +75,11 @@ semver_match() {
75
75
  # onto their tag.
76
76
  list_remote_versions() {
77
77
  local url="$1"
78
+ assert_safe_source_url "$url"
78
79
  # `|| true` inside the pipeline: an unreachable repo must read as "no
79
80
  # versions", not kill a pipefail caller mid-command-substitution —
80
81
  # reachability is the CALLER's question (add probes it explicitly).
81
- { GIT_TERMINAL_PROMPT=0 git ls-remote --tags "$url" 2>/dev/null || true; } \
82
+ { GIT_TERMINAL_PROMPT=0 git ls-remote --tags -- "$url" 2>/dev/null || true; } \
82
83
  | awk '{
83
84
  sub(/\r$/, "")
84
85
  ref = $2
@@ -95,7 +96,8 @@ list_remote_versions() {
95
96
  # prints "<tag> <sha>". Peeled sha (the commit a tag object points at) wins.
96
97
  remote_tag_for_version() {
97
98
  local url="$1" ver="${2#v}"
98
- { GIT_TERMINAL_PROMPT=0 git ls-remote --tags "$url" 2>/dev/null || true; } \
99
+ assert_safe_source_url "$url"
100
+ { GIT_TERMINAL_PROMPT=0 git ls-remote --tags -- "$url" 2>/dev/null || true; } \
99
101
  | awk -v want="$ver" '
100
102
  {
101
103
  sub(/\r$/, "")
@@ -0,0 +1 @@
1
+ 557cde6997dce612e0e8a5a11adc2472173dcec0