nexarch 0.13.0 → 0.13.2

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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Terraform state projector (ADR: infrastructure-as-code ingestion, v1).
3
+ *
4
+ * Turns `terraform show -json` into the small, safe projection that ingestion
5
+ * is allowed to send. This is the security-critical component of the feature:
6
+ * state files carry generated passwords, storage keys, connection strings and
7
+ * certificate material in clear text, verified against a real estate.
8
+ *
9
+ * Two independent gates, both deny-by-default:
10
+ *
11
+ * 1. The catalogue names, per resource type, the attributes that may be read.
12
+ * Anything not named is dropped. The catalogue is reference data served by
13
+ * the platform, not a list baked into this CLI, so support for a new Azure
14
+ * service is a data change rather than a release.
15
+ *
16
+ * 2. Terraform's own `sensitive_values` map is consulted for every attribute
17
+ * that survives gate 1, and anything flagged there is dropped even when the
18
+ * catalogue names it. A mistake in the curated list is therefore not on its
19
+ * own sufficient to leak a secret.
20
+ *
21
+ * Values are additionally restricted to scalars and shallow string arrays.
22
+ * Nested blocks are never emitted wholesale: a container app's `secret` block or
23
+ * an env var's `value` can hold credentials, so structure is only ever read by a
24
+ * named extractor that has been reviewed for that purpose.
25
+ */
26
+ /**
27
+ * Parses a state document from raw bytes.
28
+ *
29
+ * PowerShell's `>` redirection writes UTF-16 with a BOM, which is what a Windows
30
+ * user gets from `terraform show -json > state.json` and what a naive reader
31
+ * chokes on. Both endiannesses and plain UTF-8 are accepted.
32
+ */
33
+ export function parseIacStateBuffer(buffer) {
34
+ let text;
35
+ if (buffer[0] === 0xff && buffer[1] === 0xfe)
36
+ text = buffer.toString("utf16le");
37
+ else if (buffer[0] === 0xfe && buffer[1] === 0xff)
38
+ text = Buffer.from(buffer).swap16().toString("utf16le");
39
+ else
40
+ text = buffer.toString("utf8");
41
+ return JSON.parse(text.replace(/^/, ""));
42
+ }
43
+ /**
44
+ * Infers the environment from resource tags.
45
+ *
46
+ * Estates that tag at all tag consistently — the first real one carried
47
+ * `environment` on every resource — so the dominant value of a conventional tag
48
+ * key beats asking a human to pass a flag correctly in a pipeline. Returns null
49
+ * when there is no clear winner, leaving the caller to fall back to the
50
+ * Terraform workspace or an explicit flag.
51
+ */
52
+ export function inferEnvironmentFromTags(resources) {
53
+ const TAG_KEYS = ["environment", "env", "Environment", "Env"];
54
+ const counts = new Map();
55
+ for (const resource of resources) {
56
+ for (const key of TAG_KEYS) {
57
+ const value = resource.tags[key];
58
+ if (typeof value !== "string" || !value.trim())
59
+ continue;
60
+ const normalised = value.trim().toLowerCase();
61
+ counts.set(normalised, (counts.get(normalised) ?? 0) + 1);
62
+ break;
63
+ }
64
+ }
65
+ if (counts.size === 0)
66
+ return null;
67
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
68
+ const [environment, hits] = ranked[0];
69
+ const total = ranked.reduce((sum, [, n]) => sum + n, 0);
70
+ return { environment, confidence: hits / total };
71
+ }
72
+ /** Maps a free-text environment name onto an ontology environment subtype. */
73
+ export function environmentSubtypeFor(environment) {
74
+ const name = environment.trim().toLowerCase();
75
+ if (/^(prod|production|live)$/.test(name))
76
+ return "env_production";
77
+ if (/^(stag|staging|preprod|pre-prod|uat)$/.test(name))
78
+ return "env_staging";
79
+ if (/^(test|qa|sit)$/.test(name))
80
+ return "env_test";
81
+ if (/^(dr|failover)$/.test(name))
82
+ return "env_dr";
83
+ return "env_development";
84
+ }
85
+ /** Longest value we will carry for any single attribute. */
86
+ const MAX_VALUE_LENGTH = 400;
87
+ const MAX_ARRAY_ITEMS = 12;
88
+ function flattenResources(module, out = []) {
89
+ if (!module)
90
+ return out;
91
+ for (const resource of module.resources ?? [])
92
+ out.push(resource);
93
+ for (const child of module.child_modules ?? [])
94
+ flattenResources(child, out);
95
+ return out;
96
+ }
97
+ /**
98
+ * True when Terraform flagged this attribute as sensitive. The map mirrors the
99
+ * shape of `values`, so a `true` anywhere beneath the attribute — an env var's
100
+ * value inside a container template, for instance — disqualifies the whole
101
+ * attribute rather than just the leaf.
102
+ */
103
+ function isSensitive(sensitiveValues, attribute) {
104
+ if (!sensitiveValues || typeof sensitiveValues !== "object" || Array.isArray(sensitiveValues))
105
+ return false;
106
+ const flag = sensitiveValues[attribute];
107
+ if (flag === undefined)
108
+ return false;
109
+ if (flag === true)
110
+ return true;
111
+ return JSON.stringify(flag).includes("true");
112
+ }
113
+ /** Scalars and shallow string arrays only; anything structural is refused. */
114
+ function safeValue(value) {
115
+ if (value === null || value === undefined)
116
+ return null;
117
+ if (typeof value === "boolean" || typeof value === "number")
118
+ return value;
119
+ if (typeof value === "string")
120
+ return value.length > MAX_VALUE_LENGTH ? value.slice(0, MAX_VALUE_LENGTH) : value;
121
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
122
+ return value.slice(0, MAX_ARRAY_ITEMS).map((item) => item.slice(0, MAX_VALUE_LENGTH));
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * Tags carry the binding convention, so they are read — but a tag whose value
128
+ * Terraform flagged, or which does not look like a label, is left behind.
129
+ */
130
+ function projectTags(values, sensitiveValues) {
131
+ if (isSensitive(sensitiveValues, "tags"))
132
+ return {};
133
+ const raw = values.tags;
134
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
135
+ return {};
136
+ const tags = {};
137
+ for (const [key, value] of Object.entries(raw)) {
138
+ if (typeof value !== "string")
139
+ continue;
140
+ if (value.length > 200)
141
+ continue;
142
+ tags[key] = value;
143
+ }
144
+ return tags;
145
+ }
146
+ /**
147
+ * User-assigned identity references. Reviewed extractor: identity ids are
148
+ * resource paths, never credentials, and they are how a workload is tied to the
149
+ * role assignments that define what it may reach.
150
+ */
151
+ function projectIdentityIds(values) {
152
+ const identity = values.identity;
153
+ if (!Array.isArray(identity))
154
+ return [];
155
+ const ids = [];
156
+ for (const block of identity) {
157
+ if (!block || typeof block !== "object")
158
+ continue;
159
+ const identityIds = block.identity_ids;
160
+ if (!Array.isArray(identityIds))
161
+ continue;
162
+ for (const id of identityIds)
163
+ if (typeof id === "string")
164
+ ids.push(id);
165
+ }
166
+ return ids.slice(0, MAX_ARRAY_ITEMS);
167
+ }
168
+ export function projectIacState(params) {
169
+ const { document, catalogue, environment } = params;
170
+ const byType = new Map(catalogue.map((entry) => [entry.resourceType, entry]));
171
+ const resources = flattenResources(document.values?.root_module);
172
+ const projected = [];
173
+ const unknown = new Map();
174
+ let attributesDroppedNotAllowlisted = 0;
175
+ let attributesDroppedSensitive = 0;
176
+ for (const resource of resources) {
177
+ const resourceType = typeof resource.type === "string" ? resource.type : "";
178
+ if (!resourceType)
179
+ continue;
180
+ const entry = byType.get(resourceType);
181
+ if (!entry) {
182
+ unknown.set(resourceType, (unknown.get(resourceType) ?? 0) + 1);
183
+ continue;
184
+ }
185
+ const values = (resource.values && typeof resource.values === "object" ? resource.values : {});
186
+ const sensitiveValues = resource.sensitive_values;
187
+ const attributes = {};
188
+ for (const [key, value] of Object.entries(values)) {
189
+ if (!entry.attributes.includes(key)) {
190
+ attributesDroppedNotAllowlisted += 1;
191
+ continue;
192
+ }
193
+ if (isSensitive(sensitiveValues, key)) {
194
+ attributesDroppedSensitive += 1;
195
+ continue;
196
+ }
197
+ const safe = safeValue(value);
198
+ if (safe === null) {
199
+ attributesDroppedNotAllowlisted += 1;
200
+ continue;
201
+ }
202
+ attributes[key] = safe;
203
+ }
204
+ projected.push({
205
+ address: typeof resource.address === "string" ? resource.address : `${resourceType}.${resource.name ?? "unknown"}`,
206
+ resourceType,
207
+ entityTypeCode: entry.entityTypeCode,
208
+ entitySubtypeCode: entry.entitySubtypeCode ?? null,
209
+ role: entry.role,
210
+ mode: typeof resource.mode === "string" ? resource.mode : "managed",
211
+ name: typeof values[entry.nameAttribute ?? "name"] === "string" ? values[entry.nameAttribute ?? "name"] : null,
212
+ attributes,
213
+ tags: projectTags(values, sensitiveValues),
214
+ identityIds: projectIdentityIds(values),
215
+ });
216
+ }
217
+ return {
218
+ environment,
219
+ iacToolVersion: typeof document.terraform_version === "string" ? document.terraform_version : null,
220
+ resources: projected,
221
+ unknownTypes: [...unknown.entries()]
222
+ .map(([resourceType, count]) => ({ resourceType, count }))
223
+ .sort((a, b) => b.count - a.count),
224
+ stats: {
225
+ resourcesSeen: resources.length,
226
+ resourcesProjected: projected.length,
227
+ attributesDroppedNotAllowlisted,
228
+ attributesDroppedSensitive,
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,138 @@
1
+ import { readdirSync, readFileSync, statSync } from "fs";
2
+ import { join } from "path";
3
+ import { detectAnsibleProject } from "./ansible-detect.js";
4
+ const HASHICORP_SHAPED = {
5
+ estateArgs: ["show", "-json"],
6
+ unitNoun: "root module",
7
+ hasWorkspaces: true,
8
+ provisions: true,
9
+ };
10
+ export const IAC_TOOLS = [
11
+ { id: "terraform", displayName: "Terraform", executable: "terraform", applyExecutable: "terraform", aliases: ["terraform"], ...HASHICORP_SHAPED },
12
+ { id: "opentofu", displayName: "OpenTofu", executable: "tofu", applyExecutable: "tofu", aliases: ["opentofu", "tofu"], ...HASHICORP_SHAPED },
13
+ {
14
+ id: "ansible",
15
+ displayName: "Ansible",
16
+ executable: "ansible-inventory",
17
+ // --export resolves group and host variables the way a playbook run would
18
+ // see them, rather than printing the raw file.
19
+ estateArgs: ["--list", "--export"],
20
+ applyExecutable: "ansible-playbook",
21
+ unitNoun: "inventory",
22
+ hasWorkspaces: false,
23
+ provisions: false,
24
+ aliases: ["ansible"],
25
+ },
26
+ ];
27
+ export function getIacTool(value) {
28
+ if (!value)
29
+ return null;
30
+ const normalised = value.trim().toLowerCase();
31
+ return IAC_TOOLS.find((tool) => tool.aliases.includes(normalised)) ?? null;
32
+ }
33
+ const IGNORED = new Set(["node_modules", ".git", ".terraform", "dist", "build", ".next"]);
34
+ /**
35
+ * Files that say how the estate is *run*. Deliberately not `.tf` or `.hcl`:
36
+ * OpenTofu reads the same HCL and kept the `terraform { ... }` block name, so
37
+ * every OpenTofu repository on earth contains the word "terraform" in its
38
+ * configuration. Treating that as evidence of the Terraform CLI made a real
39
+ * OpenTofu estate -- `terraform {}` block, `tofu apply` in CI -- come back
40
+ * ambiguous, which then threw, so the one case this feature exists for could
41
+ * not be ingested without passing --tool by hand.
42
+ */
43
+ const AUTOMATION_FILE = /\.(ya?ml|json|sh|bash|ps1|tfvars)$|^(Makefile|Taskfile\.ya?ml|justfile)$/i;
44
+ /** `.tofu` is OpenTofu-only: it is the extension Terraform will not read. */
45
+ const TOFU_SOURCE_FILE = /\.tofu$/i;
46
+ /**
47
+ * A CLI invocation, not a mention. `terraform apply`, `tofu init`, `tofu -chdir`.
48
+ * The subcommand list is what automation actually runs; "terraform" alone in a
49
+ * comment, a module source or an HCL block is not evidence of either binary.
50
+ */
51
+ // Built with String.raw, not a plain template literal: in a plain one the
52
+ // backslash escapes are consumed by the string before the regex ever sees
53
+ // them, which is how the first version of these came to match nothing at all
54
+ // and report every repository as "defaulted".
55
+ const SUBCOMMANDS = "init|plan|apply|show|destroy|validate|state|workspace|output|fmt|refresh|import|providers|-chdir";
56
+ const TERRAFORM_COMMAND = new RegExp(String.raw `(?:^|[^\w./-])terraform\s+(?:${SUBCOMMANDS})\b`, "im");
57
+ const TOFU_COMMAND = new RegExp(String.raw `(?:^|[^\w./-])tofu\s+(?:${SUBCOMMANDS})\b`, "im");
58
+ function collectEvidence(dir, depth = 0, found = { terraform: false, opentofu: false }) {
59
+ if (depth > 4 || (found.terraform && found.opentofu))
60
+ return found;
61
+ let entries;
62
+ try {
63
+ entries = readdirSync(dir);
64
+ }
65
+ catch {
66
+ return found;
67
+ }
68
+ for (const entry of entries) {
69
+ if (IGNORED.has(entry))
70
+ continue;
71
+ const full = join(dir, entry);
72
+ try {
73
+ if (statSync(full).isDirectory()) {
74
+ collectEvidence(full, depth + 1, found);
75
+ }
76
+ else if (TOFU_SOURCE_FILE.test(entry)) {
77
+ found.opentofu = true;
78
+ }
79
+ else if (AUTOMATION_FILE.test(entry)) {
80
+ // One file at a time: the first version pushed every file's full
81
+ // contents into an array before matching, which on a large estate is
82
+ // the whole repository held in memory to answer a yes/no.
83
+ const text = readFileSync(full, "utf8");
84
+ if (!found.opentofu && TOFU_COMMAND.test(text))
85
+ found.opentofu = true;
86
+ if (!found.terraform && TERRAFORM_COMMAND.test(text))
87
+ found.terraform = true;
88
+ }
89
+ }
90
+ catch { /* unreadable configuration is not evidence */ }
91
+ if (found.terraform && found.opentofu)
92
+ return found;
93
+ }
94
+ return found;
95
+ }
96
+ /**
97
+ * Detects the IaC tool from how the repository is actually driven — the
98
+ * commands its automation runs, `.tofu` files, which only OpenTofu reads, and
99
+ * Ansible's own repository shape.
100
+ *
101
+ * Terraform and OpenTofu together is genuinely ambiguous: they are the same
102
+ * tool twice, so one estate cannot be read by guessing which. **Terraform and
103
+ * Ansible together is not** — it is the most ordinary infrastructure repository
104
+ * there is, one tool provisioning and the other configuring, and they describe
105
+ * different things. So the provisioning tool wins and the signals say the
106
+ * inventory is there to be ingested too, rather than refusing a layout that is
107
+ * completely normal.
108
+ */
109
+ export function detectIacTool(dir, requestedTool) {
110
+ if (requestedTool) {
111
+ const tool = getIacTool(requestedTool);
112
+ if (!tool)
113
+ throw new Error(`Unknown IaC tool "${requestedTool}". Use terraform, opentofu or ansible.`);
114
+ return { status: "detected", tool, signals: [`--tool ${tool.id}`] };
115
+ }
116
+ const { terraform, opentofu } = collectEvidence(dir);
117
+ const ansible = detectAnsibleProject(dir);
118
+ const alsoAnsible = ansible.isAnsible ? ["Ansible is here too — ingest its inventory with --tool ansible"] : [];
119
+ if (terraform && opentofu) {
120
+ return { status: "ambiguous", tool: null, signals: ["both `terraform` and `tofu` commands are run by this repository", ...alsoAnsible] };
121
+ }
122
+ if (opentofu)
123
+ return { status: "detected", tool: IAC_TOOLS[1], signals: ["`tofu` commands or .tofu files", ...alsoAnsible] };
124
+ if (terraform)
125
+ return { status: "detected", tool: IAC_TOOLS[0], signals: ["`terraform` commands", ...alsoAnsible] };
126
+ if (ansible.isAnsible) {
127
+ const tool = IAC_TOOLS.find((candidate) => candidate.id === "ansible");
128
+ return { status: "detected", tool, signals: ansible.signals };
129
+ }
130
+ return { status: "defaulted", tool: IAC_TOOLS[0], signals: ["no IaC CLI named in automation; defaulting to Terraform"] };
131
+ }
132
+ export function iacToolOrThrow(dir, requestedTool) {
133
+ const detection = detectIacTool(dir, requestedTool);
134
+ if (detection.status === "ambiguous") {
135
+ throw new Error("Both Terraform and OpenTofu were detected. Select the state CLI explicitly with --tool terraform or --tool opentofu.");
136
+ }
137
+ return detection.tool;
138
+ }
@@ -317,22 +317,44 @@ say so rather than skipping it or inventing a target.
317
317
  `;
318
318
  const FEEDBACK_SKILL_BODY = `---
319
319
  name: nexarch-feedback
320
- description: Report a Nexarch tool problem or misleading graph answer. Use after a blocked tool error that required a workaround, a graph answer that was wrong or unhelpful enough to mislead, or an ambiguous repository agent instruction that contradicts tool behaviour. Requires the Nexarch MCP tools (nexarch_*).
320
+ description: Offer to report a Nexarch problem the moment you notice it, rather than waiting to be asked. Use when a Nexarch tool error blocked work and needed a workaround, when a graph answer was wrong or misleading, or when a repository agent instruction contradicts what the tools actually do. Ask the human before sending. Requires the Nexarch MCP tools (nexarch_*).
321
321
  ---
322
322
 
323
323
  # Nexarch Feedback
324
324
 
325
- Call \`nexarch_submit_feedback\` when a Nexarch tool error blocked the task and
326
- you had to work around it; when a graph answer was wrong or unhelpful enough to
327
- mislead; or when an instruction in the repository's agent files was ambiguous
328
- or contradicted what the tools actually do.
325
+ These are worth reporting to Nexarch: a Nexarch tool error that blocked the task
326
+ and made you work around it; a graph answer that was wrong or unhelpful enough
327
+ to mislead; an instruction in the repository's agent files that was ambiguous or
328
+ contradicted what the tools actually do.
329
329
 
330
- Make each report useful: write one concrete sentence, name the tool or surface
331
- involved, and say what you expected instead. Include reproduction detail when
332
- it clarifies the problem.
330
+ ## Offer to report it when it happens
333
331
 
334
- Do **not** report a refused write when governance was working as designed, or
335
- your own malformed call. Fix the call or follow the governance response instead.
332
+ When you notice one, raise it with the human in the next thing you say to them
333
+ — not in a closing summary or a list of open items at the end of the task. Say
334
+ in a sentence or two what looked wrong, and ask whether they would like you to
335
+ report it to Nexarch.
336
+
337
+ - If they say yes, call \`nexarch_submit_feedback\` and tell them the feedback id
338
+ you got back.
339
+ - If they say no, drop it and do not offer again for the same problem.
340
+ - Do not send a report without their yes, unless they have already told you in
341
+ this session to report problems without asking.
342
+
343
+ If you find yourself writing "this may be a Nexarch bug", that is the moment to
344
+ offer.
345
+
346
+ ## Make each report useful
347
+
348
+ Write one concrete sentence, name the tool or surface involved, and say what
349
+ you expected instead. Include reproduction detail when it clarifies the
350
+ problem. Never include secrets, credentials or customer data.
351
+
352
+ ## Do not offer to report
353
+
354
+ A refused write when governance was working as designed, or your own malformed
355
+ call. Fix the call or follow the governance response instead. Work that is
356
+ waiting on a human decision, such as a proposed application awaiting
357
+ activation, is the process working, not a fault.
336
358
  `;
337
359
  export const SKILLS = [
338
360
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Your architecture workspace for AI delivery.",
5
5
  "keywords": [
6
6
  "nexarch",
@@ -26,7 +26,7 @@
26
26
  "prepublishOnly": "tsc",
27
27
  "dev": "tsx src/index.ts",
28
28
  "typecheck": "tsc --noEmit",
29
- "test": "tsx scripts/test-mcp-proxy-response.ts && tsx scripts/test-trust-reattest.ts && tsx scripts/test-similar-applications.ts"
29
+ "test": "tsx scripts/test-mcp-proxy-response.ts && tsx scripts/test-trust-reattest.ts && tsx scripts/test-similar-applications.ts && tsx scripts/test-iac-tool.ts && tsx scripts/test-ansible.ts && tsx scripts/test-reference-sightings.ts"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22",