graph-indexer 2.1.1 → 3.0.1

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 (131) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +236 -312
  3. package/SECURITY.md +53 -58
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +129 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +366 -0
  14. package/src/cli/init.mjs +272 -0
  15. package/src/cli/resident.mjs +185 -0
  16. package/src/index/discover.mjs +128 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +332 -0
  21. package/src/index/resolver.mjs +961 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +974 -0
  25. package/src/parse/arity.mjs +153 -0
  26. package/src/parse/extract.mjs +1038 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +345 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +683 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +130 -0
  44. package/src/query/textsearch.mjs +178 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/git.mjs +30 -0
  49. package/src/util/paths.mjs +63 -0
  50. package/src/util/regex.mjs +2 -0
  51. package/vendor/grammars/bash.wasm.gz +0 -0
  52. package/vendor/grammars/c.wasm.gz +0 -0
  53. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  54. package/vendor/grammars/cpp.wasm.gz +0 -0
  55. package/vendor/grammars/css.wasm.gz +0 -0
  56. package/vendor/grammars/go.wasm.gz +0 -0
  57. package/vendor/grammars/java.wasm.gz +0 -0
  58. package/vendor/grammars/javascript.wasm.gz +0 -0
  59. package/vendor/grammars/kotlin.wasm.gz +0 -0
  60. package/vendor/grammars/manifest.json +148 -0
  61. package/vendor/grammars/php.wasm.gz +0 -0
  62. package/vendor/grammars/python.wasm.gz +0 -0
  63. package/vendor/grammars/ruby.wasm.gz +0 -0
  64. package/vendor/grammars/rust.wasm.gz +0 -0
  65. package/vendor/grammars/scala.wasm.gz +0 -0
  66. package/vendor/grammars/tsx.wasm.gz +0 -0
  67. package/vendor/grammars/typescript.wasm.gz +0 -0
  68. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  69. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  70. package/cli-ui.mjs +0 -78
  71. package/colbert.mjs +0 -168
  72. package/config.mjs +0 -494
  73. package/daemon-ctl.mjs +0 -175
  74. package/daemon-lock.mjs +0 -69
  75. package/embedders/python/mlx_embed_server.py +0 -61
  76. package/embedders/python/requirements-mlx.txt +0 -5
  77. package/embedders/setup-mlx.mjs +0 -110
  78. package/embeddings.mjs +0 -571
  79. package/engine/binary.mjs +0 -437
  80. package/engine/memory.mjs +0 -1003
  81. package/engine/sqlite.mjs +0 -1136
  82. package/enrichment.mjs +0 -521
  83. package/git-signals.mjs +0 -248
  84. package/indexer.mjs +0 -546
  85. package/init.mjs +0 -1809
  86. package/layout.mjs +0 -123
  87. package/mcp/centrality.mjs +0 -117
  88. package/mcp/format.mjs +0 -61
  89. package/mcp/resolver.mjs +0 -116
  90. package/mcp/symbolgraph.mjs +0 -137
  91. package/mcp/taint.mjs +0 -225
  92. package/mcp/tools.mjs +0 -1582
  93. package/mcp/topology.mjs +0 -676
  94. package/mcp-server.mjs +0 -197
  95. package/parse/extractor.mjs +0 -628
  96. package/parse/imports.mjs +0 -365
  97. package/parse/interprocedural.mjs +0 -134
  98. package/parse/languages.mjs +0 -253
  99. package/parse/metadata.mjs +0 -701
  100. package/parse/routes.mjs +0 -327
  101. package/parse/scip.mjs +0 -391
  102. package/parse/taint-patterns.mjs +0 -119
  103. package/prompts/CORE.md +0 -91
  104. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  105. package/prompts/INTEGRATION.md +0 -123
  106. package/prompts/frameworks/ANDROID.md +0 -17
  107. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  108. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  109. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  110. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  111. package/prompts/frameworks/RAILS.md +0 -20
  112. package/prompts/frameworks/REACT.md +0 -20
  113. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  114. package/prompts/languages/BASH.md +0 -22
  115. package/prompts/languages/C.md +0 -21
  116. package/prompts/languages/CSHARP.md +0 -24
  117. package/prompts/languages/CSS_SCSS.md +0 -22
  118. package/prompts/languages/GO.md +0 -24
  119. package/prompts/languages/JAVA.md +0 -23
  120. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  121. package/prompts/languages/KOTLIN.md +0 -24
  122. package/prompts/languages/PHP.md +0 -23
  123. package/prompts/languages/PYTHON.md +0 -23
  124. package/prompts/languages/RUBY.md +0 -23
  125. package/prompts/languages/RUST.md +0 -21
  126. package/prompts/languages/SWIFT.md +0 -21
  127. package/seal.mjs +0 -400
  128. package/search-core.mjs +0 -777
  129. package/search-sparse.mjs +0 -183
  130. package/storage.mjs +0 -73
  131. package/watch-daemon.mjs +0 -289
@@ -1,23 +0,0 @@
1
- <environment_prompt layer="2" type="language" name="php" requires="graph-indexer-core" version="1.0">
2
-
3
- <scope>Indexed code is PHP (.php). Deltas to core; hard limits unchanged.</scope>
4
-
5
- <index_facts>
6
- <fact>Chunks are functions and WHOLE CLASSES — one class is ONE chunk with all its methods, UNTIL the class exceeds ~200 lines: a god-class is split so its methods become their own resolvable chunks. Class-sized questions on normal classes are cheap: one model/service class is one card, its full body one get_chunk.</fact>
7
- <fact>get_file_skeleton(path) parses the live file and lists EVERY method + line ranges (~80 tok) — THE tool for a large class. One skeleton call replaces searching method names inside it.</fact>
8
- <fact priority="critical">Topology WEAK BY DESIGN: only include/require tracked, namespace `use` imports are NOT. In autoloaded (PSR-4) code Deps:/Used by: are sparse/empty and mean NOTHING about usage. Map consumers by name search: chunks mentioning "ClassName" surface alongside its definition.</fact>
9
- <fact>Closures and arrow functions have no symbol — callback logic belongs to the enclosing function/class chunk.</fact>
10
- </index_facts>
11
-
12
- <rules>
13
- <rule name="batched-class-questions">"Explain classes A, B, C, D (and where used)" = ONE search_code(query: "A B C D", detail: "smart", top_k: 2×N). Definitions rank top (name boost), consumers follow. Escalate ONCE to detail: "full" + token_budget (e.g. 3000) when complete bodies of several small classes are needed. NEVER one resolve_symbol/search per class — the budget-killing failure.</rule>
14
- <rule name="usage-mapping">Consumers of ONE class: search_code(query: "what it's used for", exact_tokens: "ClassName") — REPLACES the missing Used by:. get_call_graph works for function/method NAMES, not class references.</rule>
15
- <rule name="one-method-of-a-class">In a normal (sub-200-line) class, method names aren't independently resolvable: get_file_skeleton(file) for the method map + line range, or get_chunk_summary(classChunkId) for the interface — only then decide if the full body is worth it. In a god-class (≥200 lines) the methods ARE their own chunks, so resolve_symbol / search_code on the method name hits directly.</rule>
16
- <rule name="magic-methods">__call/__callStatic/__get/__set/__invoke route behaviour with NO edges. If a called method isn't in the class chunk, check its magic methods and `use TraitName;` lines — trait methods live in the TRAIT's chunk; resolve ONE trait.</rule>
17
- <rule name="dynamic-dispatch">`$obj->$method()`, call_user_func, 'Class::method' strings create no edges — search the name as a string literal. Dispatch binds by name: get_call_graph("handle") mixes every handle; confirm receivers via class context.</rule>
18
- <rule name="namespaces">Resolve SHORT class names; the file path mirrors the namespace under PSR-4 — disambiguate from paths on the cards, no extra calls.</rule>
19
- </rules>
20
-
21
- <playbook question="explain models Country, Community, Region, Location and where used" calls="1-2">ONE search_code(query: "Country Community Region Location", detail: "smart", top_k: 10) → the four model cards (relation methods in the smart body lines) plus the controllers/components mentioning them → answer structure AND usage. Optional 2nd call: detail: "full", token_budget: 3000 only if every method body must be quoted. Four resolve_symbol calls here = budget dead.</playbook>
22
-
23
- </environment_prompt>
@@ -1,23 +0,0 @@
1
- <environment_prompt layer="2" type="language" name="python" requires="graph-indexer-core" version="1.0">
2
-
3
- <scope>Indexed code is Python (.py). Deltas to core; hard limits unchanged.</scope>
4
-
5
- <index_facts>
6
- <fact>Chunks: top-level defs, classes, methods. Decorator lines are INSIDE the chunk — routing/caching/permission behaviour is visible in summaries and searchable as body text.</fact>
7
- <fact>Topology RELIABLE — absolute and relative imports tracked.</fact>
8
- <fact>Calling a class — `UserService(...)` — records a call to the CLASS name: get_call_graph("UserService") finds instantiation sites; get_call_graph("__init__") is noise.</fact>
9
- <fact>Module-level statements (constants, registrations, import-time side effects) live outside any function — if behaviour happens "at import time", get_file_skeleton(path) shows it; don't hunt for a function that doesn't exist.</fact>
10
- </index_facts>
11
-
12
- <rules>
13
- <rule name="name-overmatch">Dispatch binds by NAME and Python is duck-typed: get_call_graph("save") mixes every save repo-wide, and resolve_symbol may return several — pick by file path + class context from the cards, no extra reads. If your index was built --resolver scip (scip-python), find_references returns a 🎯 SCIP-resolved set that disambiguates the right one — trust it over name-only.</rule>
14
- <rule name="dunder-magic">Implicit protocol calls have no edges: `obj()`↛__call__, `a+b`↛__add__, attr access↛__getattr__, `with`↛__enter__/__exit__. An empty call graph on a dunder means nothing.</rule>
15
- <rule name="package-barrels">`__init__.py` re-export hubs produce re_export nodes: internal origin → ONE hop; external dependency → stop.</rule>
16
- <rule name="decorators-wrap">Call sites point at the DECORATED name even when a decorator wraps it. If runtime behaviour is the question, read the decorator's chunk ONCE — never trace every wrapped function.</rule>
17
- <rule name="string-references">getattr/importlib/task-queue registrations and monkey-patching reference code by STRING — no edges. Trace by searching the string literal; when observed behaviour contradicts the source, ONE search of the patched name as a literal.</rule>
18
- <rule name="query-style">snake_case splits perfectly ("parse_request_body" matches "parse request body") and docstrings are indexed — query with docstring vocabulary. For duck-typed behaviour use a behavioural sentence, not the method name.</rule>
19
- </rules>
20
-
21
- <playbook question="explain function/class X" calls="2-3">resolve_symbol("X") (decorators visible) → get_chunk_summary(id, expand_calls: true) → answer with Used by: list; ≤1 consumer OR the ONE behaviour-defining decorator.</playbook>
22
-
23
- </environment_prompt>
@@ -1,23 +0,0 @@
1
- <environment_prompt layer="2" type="language" name="ruby" requires="graph-indexer-core" version="1.0">
2
-
3
- <scope>Indexed code is Ruby (.rb). Deltas to core; hard limits unchanged.</scope>
4
-
5
- <index_facts>
6
- <fact>Chunks: methods, singleton methods (`def self.x`), classes, MODULES. Instance vs class methods of the same name are DIFFERENT chunks — check the signature for which you hold.</fact>
7
- <fact priority="critical">Topology WEAK: only require/require_relative tracked, and most apps autoload — Deps:/Used by: are sparse/empty and mean NOTHING about usage. Map consumers by name search: bodies are indexed, so chunks mentioning "ClassName" surface in the same search as its definition.</fact>
8
- <fact>Reopened classes produce MULTIPLE chunks for one name — reopening, not duplication; treat them together, read only the one matching your concern.</fact>
9
- <fact>Blocks (`do...end`, `{ }`) have no symbol — logic passed to each/map/DSL methods belongs to the enclosing chunk; find it behaviourally.</fact>
10
- <fact>find_references: callers + extends/includes channels only; type_refs (annotation-usage) channel is EMPTY — Ruby is dynamically typed, no annotation tracking. "Where is this class used as a type" must be answered by name search, not find_references.</fact>
11
- </index_facts>
12
-
13
- <rules>
14
- <rule name="metaprogrammed-methods">define_method, method_missing, attr_accessor/attr_reader, delegate macros create methods with NO chunks. If resolve_symbol misses a method callers clearly use, ONE search of the SYMBOL LITERAL (exact_tokens: ":method_name") or the macro line — the macro call IS the definition.</rule>
15
- <rule name="duck-typed-dispatch">Everything dispatches by NAME: get_call_graph("call")/("perform") overmatches massively — prefer rarer names as graph entry points and confirm receivers from context. send/public_send invoke by symbol with no edge — search the symbol literal.</rule>
16
- <rule name="mixins">Methods arrive via include/extend/prepend — if a class responds to a method it doesn't define, its include lines (in the class chunk) name the module; resolve ONE module.</rule>
17
- <rule name="implicit-hooks">included/inherited/method_added hooks and yield-passed blocks run with no inbound edges — not dead code; the behaviour of `process { }` splits between the method chunk and the ONE call site that matters.</rule>
18
- <rule name="query-style">snake_case splits perfectly; pin ?/! suffixed names with exact_tokens. For DSL-heavy code search the DSL keyword or symbol literal ("validates", ":before_save") — the declaration site is the behaviour. Resolve namespaced constants by the LAST segment, disambiguate by path.</rule>
19
- </rules>
20
-
21
- <playbook question="explain classes A, B, C (and where used)" calls="1-2">ONE search_code(query: "A B C", detail: "smart", top_k: 2×N): definitions rank top, consumers (bodies mentioning the names) follow — this REPLACES the missing Used by: topology. Escalate ONE chunk to full body only if needed.</playbook>
22
-
23
- </environment_prompt>
@@ -1,21 +0,0 @@
1
- <environment_prompt layer="2" type="language" name="rust" requires="graph-indexer-core" version="1.0">
2
-
3
- <scope>Indexed code is Rust (.rs). Deltas to core; hard limits unchanged.</scope>
4
-
5
- <index_facts>
6
- <fact>Chunks: top-level fns, impl-block methods, traits, structs, enums. Attribute lines (#[...]) are part of the chunk — derives and attribute macros are visible in summaries and often ARE the behaviour.</fact>
7
- <fact>Topology RELIABLE — `use` declarations tracked.</fact>
8
- <fact priority="critical">MACROS ARE NOT EXPANDED: code from #[derive(...)], attribute macros, and macro_rules! has NO chunks, and calls written inside macro invocations may be missing from the call graph. If an impl that "must exist" is unfindable (e.g. Serialize for a struct), the struct's derive attribute IS the answer — never hunt for generated bodies.</fact>
9
- </index_facts>
10
-
11
- <rules>
12
- <rule name="hidden-calls">Rust inserts calls the source never spells: `?`→From::from, operators→Add::add etc., Deref coercion→deref, scope end→Drop::drop, `.await`→poll. None appear as edges — never call a trait method unused from an empty call graph.</rule>
13
- <rule name="trait-dispatch">Calls through generics (T: Trait) or dyn Trait bind to the method NAME: get_call_graph("execute") mixes every execute. Disambiguate by the impl target in each signature. Enumerate implementors: ONE search_code(query: "impl TraitName for", exact_tokens: "TraitName"). If your index was built --resolver scip (rust-analyzer --scip), find_references returns a precise 🎯 SCIP-resolved set that separates same-named methods — trust it over name-only.</rule>
14
- <rule name="re-exports">`pub use` hubs (crate roots, preludes) produce re_export nodes: internal origin → ONE hop; external crate → stop.</rule>
15
- <rule name="closures-and-generics">Logic inside closures passed to combinators has no edges — behavioural search. A generic fn is ONE chunk regardless of instantiations; per-type behaviour comes from its trait bounds — resolve the bound trait's method ONCE, per-type copies don't exist.</rule>
16
- <rule name="query-style">snake_case splits well; pin PascalCase trait/type names with exact_tokens. For error flow, search the error variant name or the thiserror/anyhow message string — far more unique than "error". Trust card file paths over guessed `crate::` paths.</rule>
17
- </rules>
18
-
19
- <playbook question="explain type X and its trait impls" calls="1-2">ONE search_code(query: "X impl trait", top_k: 8) — the struct chunk and its impl chunks surface together (name boost); attributes on the struct card answer derive questions. Escalate ONE chunk to full body only if implementation detail is the question.</playbook>
20
-
21
- </environment_prompt>
@@ -1,21 +0,0 @@
1
- <environment_prompt layer="2" type="language" name="swift" requires="graph-indexer-core" version="1.0">
2
-
3
- <scope>Indexed code is Swift (.swift). Deltas to core; hard limits unchanged.</scope>
4
-
5
- <index_facts>
6
- <fact priority="critical">struct, class, enum, extension, actor ALL appear with card type `class_declaration` — the card does NOT distinguish them, so READ THE KEYWORD in the signature before reasoning about value vs reference semantics. A god-type is split so its methods become their own chunks.</fact>
7
- <fact priority="critical">A type's behaviour is SPREAD across its base declaration AND every `extension` — each is a separate chunk, all carry the SAME name, possibly in different files. resolve_symbol("Point") returns the struct AND every extension; protocol conformance is usually in an extension. Read them together, not just the first card.</fact>
8
- <fact>Topology WEAK: `import Module` is recorded but Swift resolves by MODULE not file path — an empty Used by: is uninformative, never "unused".</fact>
9
- </index_facts>
10
-
11
- <rules>
12
- <rule name="protocol-dispatch">Protocol calls bind by NAME across every conformer: get_call_graph("draw") mixes every draw(). Disambiguate by receiver type in each card. Enumerate conformers: ONE search_code(query: "conform protocol", exact_tokens: "ProtocolName") — extension conformances surface by the protocol name.</rule>
13
- <rule name="attributes-are-behaviour">Property wrappers/attributes are part of the chunk and frequently ARE the behaviour: @Published, @State/@Binding, @MainActor, @objc, @escaping. A computed property's logic lives in the PROPERTY declaration, not a synthesized getX accessor.</rule>
14
- <rule name="synthesized-has-no-chunk">Memberwise inits, synthesized Codable/Equatable/Hashable, enum case accessors have NO chunks — a "missing" member matching that list means the type declaration (or its Codable conformance) IS the answer.</rule>
15
- <rule name="closures-and-builders">Trailing closures/combinators (`.map { }`, `Task { }`, `DispatchQueue.async { }`) record a call to the combinator only; SwiftUI `body` and result builders generate view structure with no edges. Closure logic belongs to the enclosing chunk — find it behaviourally. `super.m()`/`self.m()` record receiver hints.</rule>
16
- <rule name="query-style">camelCase splits well; pin Type names and argument labels with exact_tokens. A method written `distance(to:)` is searched by its base name `distance`. For error flow, search the thrown error case name or its message string.</rule>
17
- </rules>
18
-
19
- <playbook question="explain type X and its conformances" calls="1-3">ONE search_code(query: "X", exact_tokens: "X") — the base declaration and its extension chunks surface together via the name boost; read keyword + attributes off the cards → get_chunk_summary(id, expand_calls: true) on the primary chunk. ≤1 protocol hop.</playbook>
20
-
21
- </environment_prompt>
package/seal.mjs DELETED
@@ -1,400 +0,0 @@
1
- /**
2
- * @file seal.mjs
3
- * @description Sealed mode (F1): turn "air-gapped by default" from a behaviour you trust into a
4
- * guarantee you can verify and enforce. Two opt-in tiers:
5
- * • strict — zero network egress; lexical-only (the default path).
6
- * • local — loopback only; local LLM/embedders on this box are allowed, nothing
7
- * leaves it.
8
- *
9
- * Three mechanisms:
10
- * 1. assertSealCompatible(config) — FAIL-CLOSED validation: refuse to start if any
11
- * enabled feature would egress beyond the tier, naming the feature + remedy.
12
- * 2. installEgressGuard({allow}) — a deny-by-default runtime hook on the in-process
13
- * network paths (net/tls sockets, http(s), fetch) so an *accidental* call is
14
- * caught, not just the known ones. A best-effort child_process denylist covers
15
- * the obvious out-of-process egress tools (documented limitation: a child
16
- * process has its own socket layer the parent cannot hook).
17
- * 3. sealManifest(config) — a deterministic attestation document (tier, providers,
18
- * egress posture) an auditor/CI can diff against policy.
19
- *
20
- * Sealed mode is itself opt-in and can only REMOVE capability (fail-closed); it never
21
- * changes ranking, the store, or parity. The default path is already strict-compatible.
22
- *
23
- * Language grammars (parse/languages.mjs) are a separate, asset-availability form of
24
- * fail-closed: they are not bundled dependencies (installed on demand into
25
- * .graph-indexer/node_modules), so under sealed mode auto-install is disabled and
26
- * indexer.mjs throws SealViolation if a configured language's grammar isn't already
27
- * resolvable — pre-install before sealing rather than silently shipping a partial index.
28
- * @author MaquinaTech <https://github.com/MaquinaTech>
29
- * @copyright (c) 2026 MaquinaTech. All rights reserved.
30
- * @license MIT
31
- */
32
- import http from 'node:http';
33
- import https from 'node:https';
34
- import net from 'node:net';
35
- import cp from 'node:child_process';
36
- import crypto from 'node:crypto';
37
-
38
- /** Thrown by the fail-closed validation and by the runtime guard. */
39
- export class SealViolation extends Error {
40
- constructor(message) { super(message); this.name = 'SealViolation'; }
41
- }
42
-
43
- const LOOPBACK_RE = /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|::1|0:0:0:0:0:0:0:1)$/i;
44
-
45
- /** Is a bare host string a loopback address? (IPv6 brackets tolerated.) */
46
- export function isLoopbackHost(host) {
47
- if (!host) return false;
48
- const h = String(host).replace(/^\[|\]$/g, '').trim().toLowerCase();
49
- return LOOPBACK_RE.test(h);
50
- }
51
-
52
- /** Is a URL string loopback-bound? Unparseable → false (treated as non-loopback). */
53
- export function isLoopbackUrl(url) {
54
- if (!url) return false;
55
- try { return isLoopbackHost(new URL(String(url)).hostname); } catch { return false; }
56
- }
57
-
58
- /** The LLM endpoint a feature would hit, given the active provider. */
59
- function llmHost(config) {
60
- return config.llmProvider === 'mlx' ? config.mlxLmHost : config.ollamaHost;
61
- }
62
-
63
- /**
64
- * Enumerate the enabled features that make network calls, each tagged with its reach
65
- * ('loopback' = this box only, 'remote' = off-box) and a remedy. The default config (every
66
- * optional feature off) returns [].
67
- *
68
- * @param {object} config Resolved config.
69
- * @returns {Array<{feature:string,reach:'loopback'|'remote',target:string,remedy:string}>}
70
- */
71
- export function egressingFeatures(config) {
72
- const out = [];
73
- const reachOf = (host) => (isLoopbackHost(host) || isLoopbackUrl(host) ? 'loopback' : 'remote');
74
-
75
- if (config.embeddingsEnabled) {
76
- const p = config.embedProvider;
77
- if (p === 'ollama' || p === 'auto') {
78
- out.push({
79
- feature: `embeddings (${p === 'auto' ? 'auto→ollama' : 'ollama'})`,
80
- reach: reachOf(config.ollamaHost), target: config.ollamaHost,
81
- remedy: 'drop --embeddings, or point OLLAMA_HOST at loopback under --sealed local',
82
- });
83
- } else if (p === 'local') {
84
- out.push({
85
- feature: 'embeddings (local MiniLM)', reach: 'remote',
86
- target: 'model CDN (first-run download)',
87
- remedy: 'pre-warm the model cache offline, or drop --embeddings under sealed mode',
88
- });
89
- } else if (p === 'code-local') {
90
- // Same in-process @huggingface/transformers path as 'local' — the code model is
91
- // fetched from the model CDN on the FIRST run (air-gapped only once cached).
92
- out.push({
93
- feature: 'embeddings (code-local code-specialized)', reach: 'remote',
94
- target: 'model CDN (first-run download)',
95
- remedy: 'pre-warm the code model cache offline, or drop --embeddings under sealed mode',
96
- });
97
- } else if (p === 'mlx') {
98
- out.push({
99
- feature: 'embeddings (mlx subprocess)', reach: 'loopback',
100
- target: 'local mlx subprocess',
101
- remedy: 'drop --embeddings under --sealed strict (local compute only under --sealed local)',
102
- });
103
- }
104
- }
105
- if (config.enrichment?.enabled) {
106
- out.push({
107
- feature: 'enrichment (LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
108
- remedy: 'drop --enrichment under sealed mode',
109
- });
110
- }
111
- if (config.rerank?.enabled) {
112
- if (config.rerank.provider === 'cross-encoder') {
113
- out.push({
114
- feature: 'rerank (cross-encoder)', reach: 'remote',
115
- target: 'model CDN (first-run download)',
116
- remedy: 'pre-warm the model cache offline, or drop --rerank under sealed mode',
117
- });
118
- } else {
119
- out.push({
120
- feature: 'rerank (generative LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
121
- remedy: 'drop --rerank under sealed mode',
122
- });
123
- }
124
- }
125
- if (config.hyde?.enabled) {
126
- out.push({
127
- feature: 'hyde (LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
128
- remedy: 'disable hyde under sealed mode',
129
- });
130
- }
131
- return out;
132
- }
133
-
134
- /**
135
- * FAIL-CLOSED validation. Throws SealViolation (listing every offending feature) if the config
136
- * would egress beyond its tier. No-op when sealed is 'off'. Called from resolveConfig so a
137
- * sealed-incompatible config can never be constructed.
138
- *
139
- * @param {object} config Resolved config (reads config.sealed ∈ {'off','local','strict'}).
140
- */
141
- export function assertSealCompatible(config) {
142
- const tier = config.sealed || 'off';
143
- if (tier === 'off') return;
144
- const feats = egressingFeatures(config);
145
- // strict forbids ANY egress; local forbids only off-box ('remote') reach.
146
- const violations = tier === 'strict' ? feats : feats.filter(f => f.reach === 'remote');
147
- if (violations.length === 0) return;
148
- const detail = violations.map(v => ` • ${v.feature} → ${v.reach} (${v.target}); ${v.remedy}`).join('\n');
149
- throw new SealViolation(
150
- `--sealed ${tier} refuses to start — ${violations.length} enabled feature(s) would egress`
151
- + (tier === 'strict' ? ' (strict = lexical-only, zero network):\n' : ' beyond loopback:\n')
152
- + detail);
153
- }
154
-
155
- // ── Runtime egress guard ─────────────────────────────────────────────────────────────────────
156
-
157
- function hostFromHttpArgs(args) {
158
- const a = args[0];
159
- if (typeof a === 'string') { try { return new URL(a).hostname; } catch { return 'localhost'; } }
160
- if (a && typeof a === 'object') {
161
- if (typeof a.href === 'string') { try { return new URL(a.href).hostname; } catch { /* fall through */ } }
162
- const h = a.hostname || a.host || 'localhost';
163
- return String(h).replace(/:\d+$/, '');
164
- }
165
- return 'localhost';
166
- }
167
-
168
- function hostFromNetArgs(args) {
169
- const a = args[0];
170
- if (a && typeof a === 'object') {
171
- if (a.path) return null; // IPC / unix socket — local, not network
172
- return a.host || 'localhost';
173
- }
174
- if (typeof a === 'number') return typeof args[1] === 'string' ? args[1] : 'localhost';
175
- if (typeof a === 'string') return null; // IPC path
176
- return 'localhost';
177
- }
178
-
179
- function hostFromFetch(input) {
180
- if (typeof input === 'string') { try { return new URL(input).hostname; } catch { return null; } }
181
- if (input && typeof input === 'object') {
182
- if (typeof input.url === 'string') { try { return new URL(input.url).hostname; } catch { return null; } }
183
- try { return new URL(String(input)).hostname; } catch { return null; }
184
- }
185
- return null;
186
- }
187
-
188
- const EGRESS_BINS = /^(curl|wget|nc|ncat|telnet|ssh|scp|sftp|rsync)$/i;
189
- const GIT_NET = /^(fetch|pull|push|clone|remote|ls-remote|submodule)$/i;
190
- const PKG_MGRS = /^(npm|pnpm|yarn|pip|pip3|gem|cargo|go)$/i;
191
- const PKG_NET = /\b(install|add|update|upgrade|ci|fetch|download|get)\b/i;
192
-
193
- /** Best-effort: would this child process reach the network? (documented as not airtight.) */
194
- export function commandWouldEgress(cmd, args = []) {
195
- const base = String(cmd || '').split(/[\\/]/).pop();
196
- if (EGRESS_BINS.test(base)) return true;
197
- if (/^git$/i.test(base) && args[0] && GIT_NET.test(String(args[0]))) return true;
198
- if (PKG_MGRS.test(base) && args.some(a => PKG_NET.test(String(a)))) return true;
199
- // shell strings (exec/execSync) arrive as the whole command in `cmd`.
200
- if (!args.length && /\b(curl|wget|nc|ncat|ssh|scp|rsync)\b/i.test(String(cmd))) return true;
201
- if (!args.length && /\bgit\s+(fetch|pull|push|clone|remote|ls-remote)\b/i.test(String(cmd))) return true;
202
- return false;
203
- }
204
-
205
- let _installed = null;
206
-
207
- /**
208
- * Install the deny-by-default egress guard. Idempotent. The in-process socket/http/fetch hooks are
209
- * airtight for THIS process; the child_process denylist is best-effort (a child has its own socket
210
- * layer). Returns a handle with restore() (used by tests + an orderly shutdown).
211
- *
212
- * @param {object} [opts]
213
- * @param {Array<'loopback'>} [opts.allow] [] (strict) → block all; ['loopback'] (local) → permit loopback.
214
- */
215
- export function installEgressGuard({ allow = [] } = {}) {
216
- if (_installed) return _installed;
217
- const allowLoopback = allow.includes('loopback');
218
- const permit = (host) => host !== null && allowLoopback && isLoopbackHost(host);
219
- const block = (host, api) => {
220
- throw new SealViolation(`sealed mode blocked a network connection to ${host || '(unparseable host)'} via ${api}`);
221
- };
222
- // A null host from net means IPC/unix socket (local) → allow; from fetch means unparseable → block.
223
- const guardNet = (host, api, orig, ctx, args) => {
224
- if (host === null || permit(host)) return orig.apply(ctx, args);
225
- return block(host, api);
226
- };
227
-
228
- const orig = {
229
- httpRequest: http.request, httpGet: http.get,
230
- httpsRequest: https.request, httpsGet: https.get,
231
- netConnect: net.Socket.prototype.connect,
232
- fetch: globalThis.fetch,
233
- spawn: cp.spawn, spawnSync: cp.spawnSync,
234
- exec: cp.exec, execSync: cp.execSync,
235
- execFile: cp.execFile, execFileSync: cp.execFileSync,
236
- };
237
-
238
- http.request = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpRequest.apply(this, args) : block(h, 'http.request'); };
239
- http.get = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpGet.apply(this, args) : block(h, 'http.get'); };
240
- https.request = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpsRequest.apply(this, args) : block(h, 'https.request'); };
241
- https.get = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpsGet.apply(this, args) : block(h, 'https.get'); };
242
- net.Socket.prototype.connect = function (...args) { return guardNet(hostFromNetArgs(args), 'net.connect', orig.netConnect, this, args); };
243
- if (orig.fetch) globalThis.fetch = function (input, init) { const h = hostFromFetch(input); return permit(h) ? orig.fetch.call(this, input, init) : block(h, 'fetch'); };
244
-
245
- const guardChild = (name, origFn) => function (cmd, args, ...rest) {
246
- const argv = Array.isArray(args) ? args : [];
247
- if (commandWouldEgress(cmd, argv)) throw new SealViolation(`sealed mode blocked a network-reaching child process via ${name}: ${cmd}`);
248
- return origFn.call(this, cmd, args, ...rest);
249
- };
250
- cp.spawn = guardChild('spawn', orig.spawn);
251
- cp.spawnSync = guardChild('spawnSync', orig.spawnSync);
252
- cp.exec = guardChild('exec', orig.exec);
253
- cp.execSync = guardChild('execSync', orig.execSync);
254
- cp.execFile = guardChild('execFile', orig.execFile);
255
- cp.execFileSync = guardChild('execFileSync', orig.execFileSync);
256
-
257
- _installed = {
258
- allow,
259
- restore() {
260
- http.request = orig.httpRequest; http.get = orig.httpGet;
261
- https.request = orig.httpsRequest; https.get = orig.httpsGet;
262
- net.Socket.prototype.connect = orig.netConnect;
263
- if (orig.fetch) globalThis.fetch = orig.fetch;
264
- cp.spawn = orig.spawn; cp.spawnSync = orig.spawnSync;
265
- cp.exec = orig.exec; cp.execSync = orig.execSync;
266
- cp.execFile = orig.execFile; cp.execFileSync = orig.execFileSync;
267
- _installed = null;
268
- },
269
- };
270
- return _installed;
271
- }
272
-
273
- /** Whether the guard is currently installed (for stats / idempotency checks). */
274
- export function egressGuardActive() { return _installed != null; }
275
-
276
- /**
277
- * Deterministic attestation document — the effective sealed posture an auditor/CI can diff against
278
- * policy. No timestamps in the body (stamp outside if needed) so it is reproducible.
279
- *
280
- * @param {object} config Resolved config.
281
- */
282
- export function sealManifest(config) {
283
- const tier = config.sealed || 'off';
284
- return {
285
- sealed: tier,
286
- egress: tier === 'strict' ? 'none' : tier === 'local' ? 'loopback-only' : 'unrestricted',
287
- providers: {
288
- embeddings: config.embeddingsEnabled ? config.embedProvider : 'off',
289
- enrichment: config.enrichment?.enabled ? config.llmProvider : 'off',
290
- rerank: config.rerank?.enabled ? config.rerank.provider : 'off',
291
- llm: config.llmProvider || 'ollama',
292
- },
293
- egressing_features: egressingFeatures(config).map(f => ({ feature: f.feature, reach: f.reach, target: f.target })),
294
- };
295
- }
296
-
297
- // ── Attestation signing (F1 hardening) ───────────────────────────────────────────────────────
298
- // Turn the manifest from a document you trust into one you can VERIFY: a detached signature over
299
- // its canonical bytes, so an auditor/CI proves the attested posture is authentic and untampered.
300
- // Pure local crypto (node:crypto) — no dependency, no network; works under --sealed strict.
301
-
302
- /**
303
- * Deterministic JSON: object keys sorted recursively → stable bytes for signing/verifying. (The
304
- * manifest is already emitted in a fixed order, but signing over a canonical form makes the
305
- * signature robust to any future key-order change and to a round-trip through a file.)
306
- */
307
- export function canonicalJson(value) {
308
- const norm = (v) => {
309
- if (Array.isArray(v)) return v.map(norm);
310
- if (v && typeof v === 'object') {
311
- const out = {};
312
- for (const k of Object.keys(v).sort()) out[k] = norm(v[k]);
313
- return out;
314
- }
315
- return v;
316
- };
317
- return JSON.stringify(norm(value));
318
- }
319
-
320
- /** Map an asymmetric key type to its (node digest, label) signing spec. Ed25519 uses a null digest. */
321
- function signSpec(keyType) {
322
- switch (keyType) {
323
- case 'ed25519': return { digest: null, alg: 'ed25519' };
324
- case 'ed448': return { digest: null, alg: 'ed448' };
325
- case 'rsa':
326
- case 'rsa-pss': return { digest: 'sha256', alg: 'rsa-sha256' };
327
- case 'ec': return { digest: 'sha256', alg: 'ecdsa-sha256' };
328
- default: throw new Error(`unsupported attestation key type: ${keyType}`);
329
- }
330
- }
331
-
332
- /** SHA-256 fingerprint (first 16 bytes, hex) of a public key's SPKI/DER encoding — a stable key id. */
333
- export function publicKeyFingerprint(publicKey) {
334
- const der = publicKey.export({ type: 'spki', format: 'der' });
335
- return crypto.createHash('sha256').update(der).digest('hex').slice(0, 32);
336
- }
337
-
338
- /**
339
- * Sign a seal manifest with a PEM private key (Ed25519 / RSA / EC). Returns a signed envelope
340
- * `{ manifest, signature: { alg, keyType, publicKeyFingerprint, value } }`. The signature covers the
341
- * CANONICAL JSON of the manifest, so it is reproducible and tamper-evident.
342
- *
343
- * @param {object} manifest Output of sealManifest(config).
344
- * @param {string|Buffer} privateKeyPem A PEM-encoded private key.
345
- */
346
- export function signManifest(manifest, privateKeyPem) {
347
- const privateKey = crypto.createPrivateKey(privateKeyPem);
348
- const spec = signSpec(privateKey.asymmetricKeyType);
349
- const data = Buffer.from(canonicalJson(manifest), 'utf8');
350
- const value = crypto.sign(spec.digest, data, privateKey).toString('base64');
351
- const publicKey = crypto.createPublicKey(privateKey);
352
- return {
353
- manifest,
354
- signature: {
355
- alg: spec.alg,
356
- keyType: privateKey.asymmetricKeyType,
357
- publicKeyFingerprint: publicKeyFingerprint(publicKey),
358
- value,
359
- },
360
- };
361
- }
362
-
363
- /**
364
- * Verify a signed manifest envelope against a PEM public key. Returns `{ valid, reason }` (never
365
- * throws — a malformed envelope/key is a clean `valid:false`). `valid` is true only if the signature
366
- * matches the canonical JSON of `envelope.manifest` under the declared algorithm and key.
367
- *
368
- * @param {object} envelope Output of signManifest().
369
- * @param {string|Buffer} publicKeyPem A PEM-encoded public key.
370
- */
371
- export function verifySignedManifest(envelope, publicKeyPem) {
372
- try {
373
- if (!envelope || !envelope.manifest || !envelope.signature || !envelope.signature.value) {
374
- return { valid: false, reason: 'not a signed manifest envelope (missing manifest/signature)' };
375
- }
376
- const publicKey = crypto.createPublicKey(publicKeyPem);
377
- const spec = signSpec(publicKey.asymmetricKeyType);
378
- if (spec.alg !== envelope.signature.alg) {
379
- return { valid: false, reason: `algorithm mismatch: envelope=${envelope.signature.alg}, key=${spec.alg}` };
380
- }
381
- const data = Buffer.from(canonicalJson(envelope.manifest), 'utf8');
382
- const ok = crypto.verify(spec.digest, data, publicKey, Buffer.from(envelope.signature.value, 'base64'));
383
- if (!ok) return { valid: false, reason: 'signature does not match (tampered manifest or wrong key)' };
384
- const fp = publicKeyFingerprint(publicKey);
385
- if (envelope.signature.publicKeyFingerprint && envelope.signature.publicKeyFingerprint !== fp) {
386
- return { valid: false, reason: 'public-key fingerprint mismatch' };
387
- }
388
- return { valid: true, reason: 'signature valid', publicKeyFingerprint: fp };
389
- } catch (e) {
390
- return { valid: false, reason: `verification error: ${e.message}` };
391
- }
392
- }
393
-
394
- /** Generate an Ed25519 attestation keypair as PEM strings (`idx-index --gen-attestation-key`). */
395
- export function generateAttestationKeyPair() {
396
- return crypto.generateKeyPairSync('ed25519', {
397
- publicKeyEncoding: { type: 'spki', format: 'pem' },
398
- privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
399
- });
400
- }