nexarch 0.12.44 → 0.13.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.
@@ -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
+ }
@@ -315,7 +315,53 @@ say so rather than skipping it or inventing a target.
315
315
  - End with a one-line summary: what was recorded or reused, and what (if
316
316
  anything) couldn't be linked.
317
317
  `;
318
+ const FEEDBACK_SKILL_BODY = `---
319
+ name: nexarch-feedback
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
+ ---
322
+
323
+ # Nexarch Feedback
324
+
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
+
330
+ ## Offer to report it when it happens
331
+
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.
358
+ `;
318
359
  export const SKILLS = [
360
+ {
361
+ templateCode: "nexarch_feedback_skill_v1",
362
+ dirName: "nexarch-feedback",
363
+ fallbackBody: FEEDBACK_SKILL_BODY,
364
+ },
319
365
  {
320
366
  templateCode: "nexarch_claude_code_skill_v1",
321
367
  dirName: "nexarch-architecture-graph",
package/dist/lib/trust.js CHANGED
@@ -1,9 +1,20 @@
1
1
  import https from "https";
2
+ import { createHash } from "node:crypto";
2
3
  import { requireCredentials } from "./credentials.js";
3
4
  const MCP_GATEWAY_URL = "https://mcp.nexarch.ai";
4
5
  /** The scope every attestation is minted under — see ADR-0112. Exported so callers can tell a
5
6
  * stored attestation's scope apart from what would be minted today, without duplicating the literal. */
6
7
  export const TRUST_ATTESTATION_SCOPE = "agent_config_write";
8
+ /**
9
+ * Recomputes the hash of the agent-registration managed section. The attestation
10
+ * binds to this exact trimmed body, and both minting and verification use it.
11
+ */
12
+ export function hashRegisteredSection(content) {
13
+ const match = content.match(/<!-- nexarch:agent-registration:start -->\n([\s\S]*?)\n<!-- nexarch:agent-registration:end -->/);
14
+ if (!match)
15
+ return null;
16
+ return createHash("sha256").update(match[1].trim(), "utf8").digest("hex");
17
+ }
7
18
  /**
8
19
  * `contentHash` (sha256 hex of the exact managed-section text being written)
9
20
  * binds the signature to the instructions themselves, not just to the claim
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.12.44",
3
+ "version": "0.13.1",
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"
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",