archgraph-argo 0.26.2 → 0.28.0

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.
@@ -12,6 +12,18 @@ const {
12
12
  const {
13
13
  loadRepositoryArgoEnvironment,
14
14
  } = require('./repositoryArgoEnvironment.js');
15
+ const {
16
+ loadSchemaBundleAndOntology,
17
+ } = require('./schema-bundle.js');
18
+ const {
19
+ externalQueryRequested,
20
+ queryExternalRead,
21
+ } = require('./external-graph-query.js');
22
+ const {
23
+ authorizeWorkspaceWrite,
24
+ isWriteTool,
25
+ stripTrustedSessionField,
26
+ } = require('./workspace-write-guard.js');
15
27
  const {
16
28
  getWorkspaceRoot,
17
29
  hasStaticWorkspace,
@@ -127,12 +139,12 @@ const TOOLS = [
127
139
  },
128
140
  {
129
141
  name: 'getIntentElementContext',
130
- description: 'read-only query that returns an intent subgraph context for one element. Uses ArchiMate semantic dependency traversal with dependencyDepth and dependentDepth, preserving native subgraph elements, relationships, and views.',
142
+ description: 'read-only query that returns an intent subgraph context for one element. Uses ArchiMate semantic dependency traversal with dependencyDepth and dependentDepth, preserving native subgraph elements, relationships, and views. Output is bounded by maxBytes (default ARGO_CONTEXT_MAX_BYTES or 32000): beyond it non-focus members degrade to identity (id/type/name) with a complete id manifest under truncation, so the host never silently cuts the payload. On a large/hub element leave includeAttributes/includeTestcases off and prefer queryNeo4jGraph to locate ids, then read them narrowly.',
131
143
  inputSchema: intentElementContextInputSchema(),
132
144
  },
133
145
  {
134
146
  name: 'previewSystemArchitectureMutation',
135
- description: 'Use before apply for complex or risky changes. Performs a dry-run of one or more mutations, runs schema, graph, view, and ArchiMate 3.2 validation, and does not write the graph.',
147
+ description: 'Use before apply for complex or risky changes. Performs a dry-run of one or more mutations, runs schema, graph, view, and modeling-language validation, and does not write the graph.',
136
148
  inputSchema: mutationInputSchema(),
137
149
  },
138
150
  {
@@ -142,12 +154,18 @@ const TOOLS = [
142
154
  },
143
155
  {
144
156
  name: 'addArchitectureElement',
145
- description: 'Use for one element. Creates a new element or adds an existing element to view_ids. view_ids is required so elements never exist outside views.',
157
+ description: 'Use for one element. Creates a new element or adds an existing element to view_ids. view_ids is required so elements never exist outside views. element.id is OPTIONAL: omit it and the server auto-allocates a unique id (returned in the result); provide it to pin a specific id (must not collide with a different element).',
146
158
  inputSchema: {
147
159
  type: 'object',
148
160
  required: ['element', 'view_ids'],
149
161
  properties: {
150
- element: { type: 'object' },
162
+ element: {
163
+ type: 'object',
164
+ properties: {
165
+ id: { type: 'string', description: 'Element id (OPTIONAL). Omit to let the server auto-allocate a unique id (a semantic slug from the name, e.g. "graph-wiki-federation-center", suffixed -002.. if taken). If provided it must not collide with a DIFFERENT element (a same (type,name) match is idempotent reuse).' },
166
+ },
167
+ description: 'The element to create/attach. `id` is optional — omit it and the server allocates one (returned in the result).',
168
+ },
151
169
  view_ids: { type: 'array', minItems: 1, items: { type: 'string' } },
152
170
  onConflict: { type: 'string', enum: ['reuse', 'allowDuplicate'], description: 'Dedup policy (default reuse). reuse: find-or-create — attach an existing exact (type, name) match; a same-type semantic near-duplicate also blocks creation. allowDuplicate: create anyway (even if a duplicate exists), requires a justification.' },
153
171
  justification: { type: 'string', description: 'Required when onConflict is allowDuplicate.' },
@@ -186,7 +204,7 @@ const TOOLS = [
186
204
  },
187
205
  {
188
206
  name: 'addArchitectureRelationship',
189
- description: 'Use for one relationship. Creates a new relationship or adds an existing relationship to view_ids. relationship.type is the ArchiMate 3.2 relationship type and is validated against endpoint element types.',
207
+ description: 'Use for one relationship. Creates a new relationship or adds an existing relationship to view_ids. relationship.type is the ArchiMate 3.2 relationship type and is validated against endpoint element types. relationship.id is OPTIONAL: omit it and the server auto-allocates a unique id (returned in the result).',
190
208
  inputSchema: {
191
209
  type: 'object',
192
210
  required: ['relationship', 'view_ids'],
@@ -230,7 +248,7 @@ const TOOLS = [
230
248
  },
231
249
  {
232
250
  name: 'addArchitectureView',
233
- description: 'Use for one view. The graph must have exactly one top-level view named SystemArchitecture; all sub-views must attach to an element with parent_element_id.',
251
+ description: 'Use for one view. The graph must have exactly one top-level view named SystemArchitecture; all sub-views must attach to an element with parent_element_id. view.view_id is OPTIONAL: omit it and the server auto-allocates a unique view_id (returned in the result).',
234
252
  inputSchema: {
235
253
  type: 'object',
236
254
  required: ['view'],
@@ -330,8 +348,9 @@ function intentElementContextInputSchema() {
330
348
  dependentDepth: { type: 'number', description: 'Default: 1. Semantic dependents that rely on the focus element.' },
331
349
  associationDepth: { type: 'number', description: 'Default: 1. Association neighbors are expanded at least one layer.' },
332
350
  associationNeighborDependencyDepth: { type: 'number', description: 'Default: 0. Optional dependency expansion from association neighbors.' },
333
- includeAttributes: { type: 'boolean', description: 'Default: false. Include `attributes` (commit/session/release ledgers) verbatim; omitted by default from this structural read (the focus element always keeps its own).' },
334
- includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim; omitted by default from this structural read.' },
351
+ includeAttributes: { type: 'boolean', description: 'Default: false. Include `attributes` (commit/session/release ledgers) verbatim; omitted by default from this structural read (the focus element always keeps its own). On a hub element this ledger can dominate the payload.' },
352
+ includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim; omitted by default from this structural read. On a hub element this can be large.' },
353
+ maxBytes: { type: 'number', description: 'Optional output budget in UTF-8 bytes. Default: ARGO_CONTEXT_MAX_BYTES env or 32000. 0 = unlimited. Above the budget, non-focus members degrade to identity (id/type/name) with a complete id manifest under `truncation`; the payload is never silently truncated by the host.' },
335
354
  },
336
355
  additionalProperties: false,
337
356
  };
@@ -397,6 +416,33 @@ function resolveWorkspaceRoot(args) {
397
416
  }
398
417
 
399
418
  async function callTool(name, args = {}, progressToken = null, dependencies = undefined) {
419
+ // Cross-project WRITE guard (single choke point for every tool): a write tool
420
+ // may only target this server's HOME workspace, or a host-designated trusted
421
+ // session workspace when the process was launched by a trusted broker
422
+ // (ARGO_WORKSPACE_ROOT_TRUSTED=1). A foreign `workspaceRoot` is rejected
423
+ // fail-closed before any side effect; READ tools stay ungated so cross-project
424
+ // reading still works. See workspace-write-guard.js.
425
+ if (isWriteTool(name, args)) {
426
+ const decision = authorizeWorkspaceWrite({ tool: name, args, homeRoot: getWorkspaceRoot() });
427
+ if (!decision.allowed) {
428
+ return toolResult({
429
+ status: 'failed',
430
+ error: {
431
+ category: decision.category,
432
+ message: decision.message,
433
+ tool: decision.tool,
434
+ homeWorkspaceRoot: decision.homeWorkspaceRoot,
435
+ requestedWorkspaceRoot: decision.requestedWorkspaceRoot,
436
+ ...(decision.trustedSessionWorkspaceRoot
437
+ ? { trustedSessionWorkspaceRoot: decision.trustedSessionWorkspaceRoot }
438
+ : {}),
439
+ },
440
+ });
441
+ }
442
+ }
443
+ // The broker-only field is not part of any tool's schema: honor it above, then
444
+ // drop it before dispatch so schema validation never sees it.
445
+ args = stripTrustedSessionField(args);
400
446
  loadRepositoryArgoEnvironment(resolveWorkspaceRoot(args));
401
447
  try {
402
448
  const crash = require('./graph-rag/mcpCrashDiagnostics.js');
@@ -405,6 +451,23 @@ async function callTool(name, args = {}, progressToken = null, dependencies = un
405
451
  } catch {
406
452
  // diagnostics are best-effort; never block a tool call
407
453
  }
454
+ // Cross-project read: a read tool with `projectId` is routed to the federation
455
+ // center (never silently served from the local graph).
456
+ if (externalQueryRequested(name, args)) {
457
+ const result = await queryExternalRead({
458
+ workspaceRoot: resolveWorkspaceRoot(args),
459
+ tool: name,
460
+ args,
461
+ });
462
+ // getSystemArchitecture declares an outputSchema, so its result MUST carry
463
+ // matching structuredContent even on the cross-project path (which bypasses
464
+ // the systemarchitecture module's own toolResult). A missing
465
+ // structuredContent makes the MCP client reject the call with -32600.
466
+ if (name === 'getSystemArchitecture') {
467
+ return toolResult(result, systemArchitectureMcp.buildGetSystemArchitectureStructuredContent(result));
468
+ }
469
+ return toolResult(result);
470
+ }
408
471
  if (name === 'initializeWorkspace') {
409
472
  const workspace = await initializeWorkspace(resolveWorkspaceRoot(args));
410
473
  // Deterministic argo-init harness report (Neo4j structural sync, semantic
@@ -421,6 +484,10 @@ async function callTool(name, args = {}, progressToken = null, dependencies = un
421
484
  return toolResult({
422
485
  status: report.status,
423
486
  workspaceRoot: workspace.workspaceRoot,
487
+ // The resolved modeling language for this workspace (default ArchiMate 3.2 vs
488
+ // the repository's own .argo/schema bundle) so init names the active schema.
489
+ schema: workspace.schema,
490
+ schemaBundle: report.schemaBundle,
424
491
  targetEaName: workspace.targetEaName,
425
492
  createdFiles: workspace.createdFiles,
426
493
  updatedFiles: workspace.updatedFiles,
@@ -514,6 +581,22 @@ async function initializeWorkspace(workspaceRoot) {
514
581
  const graphTargetPath = path.join(workspaceRoot, ...WORKSPACE_GRAPH_PATH_SEGMENTS);
515
582
  const graphRelativePath = normalizeRelativePath(path.relative(workspaceRoot, graphTargetPath));
516
583
  if (!fs.existsSync(graphTargetPath)) {
584
+ // Only the built-in ArchiMate 3.2 default schema auto-provides a graph (the
585
+ // packaged default). For any custom / replaced schema the graph is the user's
586
+ // own: never copy the packaged (mismatched) graph — fail closed if missing.
587
+ let ontology = null;
588
+ try { ontology = loadSchemaBundleAndOntology(workspaceRoot).ontology; } catch { ontology = null; }
589
+ const usesPackagedDefaultGraph = !!ontology && ontology.kind === 'default' && ontology.dialect === 'archimate-class-matrix';
590
+ if (!usesPackagedDefaultGraph) {
591
+ const label = ontology ? `${ontology.kind} / ${ontology.language}` : 'unknown';
592
+ const error = new Error(
593
+ `No default graph for the custom schema (${label}): the workspace has no ${graphRelativePath}. ` +
594
+ 'A custom schema must author its own graph; the packaged default graph is not copied because it would not match the schema. ' +
595
+ `Add ${graphRelativePath} to the workspace.`,
596
+ );
597
+ error.code = 'NO_DEFAULT_GRAPH';
598
+ throw error;
599
+ }
517
600
  const graphSourcePath = resolveGraphDefaultSourcePath();
518
601
  await fs.promises.mkdir(path.dirname(graphTargetPath), { recursive: true });
519
602
  await fs.promises.copyFile(graphSourcePath, graphTargetPath);
@@ -553,8 +636,28 @@ async function initializeWorkspace(workspaceRoot) {
553
636
  } catch (error) {
554
637
  qeaFullProjection = { status: 'failed', error: String(error && error.message ? error.message : error) };
555
638
  }
639
+ // Report which schema this workspace resolves to (default ArchiMate 3.2 vs a
640
+ // repository's own .argo/schema bundle) so the caller/human can see the active
641
+ // modeling language right after init.
642
+ let schema;
643
+ try {
644
+ const { bundle, ontology } = loadSchemaBundleAndOntology(workspaceRoot);
645
+ schema = {
646
+ kind: bundle.kind,
647
+ language: ontology.language,
648
+ dialect: ontology.dialect,
649
+ dir: bundle.relativeDir,
650
+ guide: bundle.guidePath ? bundle.guidePath.relativePath : null,
651
+ actorElementType: ontology.actorElementType,
652
+ bundleValidation: ontology.bundleValidation ? ontology.bundleValidation.status : null,
653
+ };
654
+ } catch (error) {
655
+ schema = { kind: 'unknown', error: String(error && error.message ? error.message : error) };
656
+ }
657
+
556
658
  return {
557
659
  workspaceRoot,
660
+ schema,
558
661
  qeaFullProjection,
559
662
  targetEaName,
560
663
  createdFiles,
@@ -621,7 +724,7 @@ function normalizeRelativePath(value) {
621
724
  return String(value).replace(/\\/g, '/');
622
725
  }
623
726
 
624
- function toolResult(payload) {
727
+ function toolResult(payload, structuredContent = undefined) {
625
728
  return {
626
729
  content: [
627
730
  {
@@ -629,11 +732,20 @@ function toolResult(payload) {
629
732
  text: JSON.stringify(payload, null, 2),
630
733
  },
631
734
  ],
735
+ ...(structuredContent === undefined ? {} : { structuredContent }),
632
736
  isError: payload.status === 'failed',
633
737
  };
634
738
  }
635
739
 
636
740
  function canonicalInitErrorResult(error) {
741
+ // Workspace-bootstrap faults (e.g. a schema with no default graph) are not
742
+ // semantic-lifecycle secrets and must surface their message to the caller.
743
+ if (error && error.code === 'NO_DEFAULT_GRAPH') {
744
+ return toolResult({
745
+ status: 'failed',
746
+ error: { category: 'WORKSPACE_INIT_FAILED', code: error.code, message: String(error.message) },
747
+ });
748
+ }
637
749
  const result = semanticOperatorErrorResult(error);
638
750
  if (error.safeSemanticLifecycleMessage !== true) return result;
639
751
  const payload = Object.freeze({
@@ -768,6 +880,7 @@ async function handleRequest(request, dependencies = undefined) {
768
880
  && params.arguments
769
881
  && Object.prototype.hasOwnProperty.call(params.arguments, 'query')
770
882
  && params.arguments.query
883
+ && !externalQueryRequested(params.name, params.arguments)
771
884
  ) {
772
885
  activeDependencies = {
773
886
  semanticOperatorJourney:
@@ -56,6 +56,20 @@ async function runHarnessReport({ checkOnly, workspaceRoot, includeBootstrap })
56
56
 
57
57
  try {
58
58
  report.harnessEnvironment = loadRepositoryArgoEnvironment(workspaceRoot);
59
+ try {
60
+ const { bundle, ontology } = require('./schema-bundle.js').loadSchemaBundleAndOntology(workspaceRoot);
61
+ report.schemaBundle = {
62
+ kind: bundle.kind,
63
+ language: ontology.language,
64
+ dialect: ontology.dialect,
65
+ dir: bundle.relativeDir,
66
+ guide: bundle.guidePath ? bundle.guidePath.relativePath : null,
67
+ actorElementType: ontology.actorElementType,
68
+ bundleValidation: ontology.bundleValidation ? ontology.bundleValidation.status : null,
69
+ };
70
+ } catch (schemaError) {
71
+ report.schemaBundle = { kind: 'unknown', error: String(schemaError && schemaError.message ? schemaError.message : schemaError) };
72
+ }
59
73
  if (includeBootstrap) {
60
74
  report.workspaceBootstrap = await ensureWorkspaceBootstrap({ checkOnly, workspaceRoot });
61
75
  }
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ // Cross-project graph query (federation client side).
4
+ //
5
+ // A read tool call may carry an optional `projectId`. Absent => the local
6
+ // workspace (unchanged). Present => the call is routed to the federation center
7
+ // (`POST <centerUrl>/graph/read`), which authorizes and forwards to the mirror
8
+ // engine; the native ARGO result is passed through with a `namespaceKey`. There
9
+ // is NO silent fallback to the local graph.
10
+ //
11
+ // The requester identity is the project's own `projectId`, read from
12
+ // `<workspace>/.argo/federation.json`. If it is missing, external queries fail
13
+ // with an explicit "register first" error — the id is never guessed.
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+
18
+ const EXTERNAL_READ_TOOLS = new Set([
19
+ 'getSystemArchitecture',
20
+ 'getIntentElementContext',
21
+ 'getArchitectureViewContext',
22
+ 'queryNeo4jGraph',
23
+ 'memory_search',
24
+ ]);
25
+
26
+ const FEDERATION_FILE = path.join('.argo', 'federation.json');
27
+ const DEFAULT_CENTER_URL = 'https://argo.derekworkspacev5.com';
28
+
29
+ function loadFederationIdentity(workspaceRoot) {
30
+ const file = path.join(workspaceRoot || process.cwd(), FEDERATION_FILE);
31
+ if (!fs.existsSync(file)) {
32
+ return null;
33
+ }
34
+ try {
35
+ const identity = JSON.parse(fs.readFileSync(file, 'utf8'));
36
+ return identity && typeof identity === 'object' ? identity : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function isExternalQuery(args) {
43
+ return Boolean(args) && typeof args.projectId === 'string' && args.projectId.trim() !== '';
44
+ }
45
+
46
+ // A tool call is external iff it is one of the readable tools AND carries projectId.
47
+ function externalQueryRequested(toolName, args) {
48
+ return EXTERNAL_READ_TOOLS.has(toolName) && isExternalQuery(args);
49
+ }
50
+
51
+ function forwardedArgs(args) {
52
+ const out = { ...(args || {}) };
53
+ // projectId is the router parameter; workspaceRoot/architecturePath are local-only.
54
+ delete out.projectId;
55
+ delete out.workspaceRoot;
56
+ delete out.architecturePath;
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Route an external read. Returns the native ARGO result (with an added
62
+ * `namespaceKey`) on success, or `{ status:'failed', error:{...} }` — never the
63
+ * local result.
64
+ *
65
+ * @param {object} options
66
+ * @param {string} options.workspaceRoot
67
+ * @param {string} options.tool
68
+ * @param {object} options.args
69
+ * @param {Function} [options.fetchImpl] - injectable fetch (tests)
70
+ */
71
+ async function queryExternalRead({ workspaceRoot, tool, args, fetchImpl }) {
72
+ const fetchFn = fetchImpl || globalThis.fetch;
73
+ if (typeof fetchFn !== 'function') {
74
+ return { status: 'failed', error: { category: 'EXTERNAL_QUERY_UNREACHABLE', message: 'global fetch is unavailable in this runtime' } };
75
+ }
76
+
77
+ const identity = loadFederationIdentity(workspaceRoot);
78
+ if (!identity || typeof identity.projectId !== 'string' || identity.projectId.trim() === '') {
79
+ return {
80
+ status: 'failed',
81
+ error: {
82
+ category: 'EXTERNAL_QUERY_NOT_REGISTERED',
83
+ message: `No federation identity at ${FEDERATION_FILE}. Register this project with the federation center first (registry_register) and write its projectId to ${FEDERATION_FILE}.`,
84
+ },
85
+ };
86
+ }
87
+
88
+ if (!EXTERNAL_READ_TOOLS.has(tool)) {
89
+ return {
90
+ status: 'failed',
91
+ error: { category: 'EXTERNAL_QUERY_TOOL_NOT_ALLOWED', message: `tool '${tool}' is not externally readable`, allowed: [...EXTERNAL_READ_TOOLS] },
92
+ };
93
+ }
94
+
95
+ const centerUrl = String(identity.centerUrl || DEFAULT_CENTER_URL).replace(/\/+$/, '');
96
+ const url = `${centerUrl}/graph/read`;
97
+ const body = {
98
+ requester: identity.projectId,
99
+ projectId: args.projectId,
100
+ tool,
101
+ args: forwardedArgs(args),
102
+ };
103
+
104
+ let response;
105
+ try {
106
+ response = await fetchFn(url, {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'application/json' },
109
+ body: JSON.stringify(body),
110
+ });
111
+ } catch (error) {
112
+ return {
113
+ status: 'failed',
114
+ error: {
115
+ category: 'EXTERNAL_QUERY_UNREACHABLE',
116
+ message: `federation center unreachable: ${error && error.message ? error.message : error}`,
117
+ centerUrl,
118
+ },
119
+ };
120
+ }
121
+
122
+ let payload = null;
123
+ try { payload = await response.json(); } catch { /* leave null */ }
124
+
125
+ if (response.status === 200 && payload && payload.status === 'ok') {
126
+ // The engine returns the ARGO tool result; unwrap a {content:[{text}]}
127
+ // envelope so callers get the native payload, then stamp the namespaceKey.
128
+ let result = payload.result;
129
+ if (result && Array.isArray(result.content) && result.content[0] && typeof result.content[0].text === 'string') {
130
+ try { result = JSON.parse(result.content[0].text); } catch { /* keep the envelope */ }
131
+ }
132
+ if (result && typeof result === 'object') {
133
+ result = { ...result, namespaceKey: payload.namespaceKey || `proj:${args.projectId}` };
134
+ }
135
+ return result;
136
+ }
137
+
138
+ const denied = Boolean(payload && payload.status === 'denied');
139
+ const reason = (payload && payload.reason)
140
+ || (response.status === 403 ? 'not_authorized' : `http_${response.status}`);
141
+ return {
142
+ status: 'failed',
143
+ error: {
144
+ category: denied ? 'EXTERNAL_QUERY_DENIED' : 'EXTERNAL_QUERY_FAILED',
145
+ reason,
146
+ httpStatus: response.status,
147
+ requester: identity.projectId,
148
+ projectId: args.projectId,
149
+ namespaceKey: `proj:${args.projectId}`,
150
+ },
151
+ };
152
+ }
153
+
154
+ module.exports = {
155
+ EXTERNAL_READ_TOOLS,
156
+ FEDERATION_FILE,
157
+ DEFAULT_CENTER_URL,
158
+ loadFederationIdentity,
159
+ isExternalQuery,
160
+ externalQueryRequested,
161
+ queryExternalRead,
162
+ };
@@ -611,19 +611,6 @@ function assertProtectedSecretFile(expectedFilePath, adapters) {
611
611
  ) {
612
612
  throw safeError('SECRET_FILE_GIT_STATE_UNSAFE');
613
613
  }
614
- const acl = adapters.acl && adapters.acl.inspect();
615
- if (!acl || acl.status !== 0 || !acl.identity || typeof acl.stdout !== 'string') {
616
- throw safeError('SECRET_FILE_ACL_UNVERIFIABLE');
617
- }
618
- const broadRead = acl.stdout
619
- .split(/\r?\n/)
620
- .some(line => /^(Everyone|BUILTIN\\Users|Authenticated Users):.*\((?:R|RX|F|M)\)/i.test(line.trim()));
621
- const identityRead = acl.stdout
622
- .split(/\r?\n/)
623
- .some(line => line.trim().startsWith(`${acl.identity}:`) && /\((?:R|RX|F|M)\)/i.test(line));
624
- if (broadRead || !identityRead) {
625
- throw safeError('SECRET_FILE_ACL_UNSAFE');
626
- }
627
614
  }
628
615
 
629
616
  function requireApprovedProfile(values) {
@@ -86,6 +86,8 @@ const LEGACY_KEYS = Object.freeze(['ARGO_NEO4J_URI', 'ARGO_NEO4J_USERNAME', 'ARG
86
86
  const HOST_ONLY_ENV_KEYS = Object.freeze([
87
87
  'ARGO_ENV_FILE',
88
88
  'ARGO_REPO_ROOT',
89
+ 'ARGO_SCHEMA_DIR',
90
+ 'ARGO_ACTOR_ELEMENT_TYPE',
89
91
  'ARGO_EA_QEA',
90
92
  'ARGO_WORKSPACE_ROOTS',
91
93
  'ARGO_SERVER_PATH',
@@ -97,6 +99,11 @@ const HOST_ONLY_ENV_KEYS = Object.freeze([
97
99
  'ARGO_MCP_SEMANTIC_DEDUP',
98
100
  'ARGO_MCP_SEMANTIC_DEDUP_THRESHOLD',
99
101
  'ARGO_SEMANTIC_DEDUP_THRESHOLD',
102
+ 'ARGO_WORKSPACE_WRITE_GUARD',
103
+ 'ARGO_WORKSPACE_ROOT_TRUSTED',
104
+ 'ARGO_COST_PROFILER',
105
+ 'ARGO_COST_TRACE_MAX_BYTES',
106
+ 'ARGO_CONTEXT_MAX_BYTES',
100
107
  ]);
101
108
  const PROHIBITED_RUNTIME_FIELD_KEYS = Object.freeze(['neo4jUri', 'embeddingCredential']);
102
109
  const SECRET_KEYS = new Set(['ARGO_NEO4J_DATABASE_PASSWORD', 'QWEN_KEY', 'ARGO_RERANK_API_KEY', EMBEDDING_API_KEY_KEY]);
@@ -451,76 +458,18 @@ function preflightFile({ canonicalFilePath, configuredFilePath, filesystem, adap
451
458
 
452
459
  let ignored;
453
460
  let tracked;
454
- let aclEvidence;
455
461
  if (adapters.systemMetadata) {
456
462
  const insideRepository = adapters.systemMetadata.isSecretFileInsideGitRepository().status === 0;
457
463
  ignored = insideRepository ? adapters.systemMetadata.isSecretFileIgnored() : true;
458
464
  tracked = insideRepository ? adapters.systemMetadata.isSecretFileTracked() : false;
459
- if (process.platform === 'win32') {
460
- const identityResult = adapters.systemMetadata.readCurrentIdentity();
461
- const aclResult = adapters.systemMetadata.readSecretFileAcl();
462
- aclEvidence = {
463
- status: aclResult.status,
464
- stdout: aclResult.stdout,
465
- identity: identityResult.status === 0 ? identityResult.stdout.trim() : '',
466
- };
467
- }
468
465
  } else {
469
466
  ignored = adapters.git.isIgnored(configuredFilePath);
470
467
  tracked = adapters.git.isTracked(configuredFilePath);
471
- aclEvidence = adapters.acl.inspect(configuredFilePath);
472
468
  }
469
+ // Only the "never committed" guarantee is enforced. OS-level ACL / file-mode
470
+ // hardening is intentionally NOT required (it caused frequent false failures).
473
471
  if (tracked) throw safeError('SECRET_FILE_TRACKED');
474
472
  if (!ignored) throw safeError('SECRET_FILE_NOT_IGNORED');
475
- if (process.platform === 'win32') {
476
- validateAcl(aclEvidence);
477
- } else {
478
- validatePosixSecretFileAcl(configuredFilePath, filesystem);
479
- }
480
- }
481
-
482
- function validateAcl(result) {
483
- if (!result || result.status !== 0 || typeof result.stdout !== 'string' || !result.identity) {
484
- throw safeError('SECRET_FILE_ACL_UNVERIFIABLE');
485
- }
486
- const permissions = parseAcl(result.stdout);
487
- const current = permissions.get(String(result.identity).toLowerCase());
488
- if (!current || !current.allow || current.deny) throw safeError('SECRET_FILE_ACL_UNSAFE');
489
- for (const broad of ['everyone', 'builtin\\users', 'authenticated users', 'nt authority\\authenticated users']) {
490
- const permission = permissions.get(broad);
491
- if (permission && permission.allow && !permission.deny) throw safeError('SECRET_FILE_ACL_UNSAFE');
492
- }
493
- }
494
-
495
- function parseAcl(output) {
496
- const result = new Map();
497
- for (const line of output.split(/\r?\n/)) {
498
- const normalizedLine = line.trim().replace(/^[A-Za-z]:\\.*?\.env\s+/, '');
499
- const match = normalizedLine.match(/^(.+?):((?:\([^)]*\))+)\s*$/);
500
- if (!match) continue;
501
- const tokens = [...match[2].matchAll(/\(([^)]*)\)/g)].map(item => item[1].toUpperCase());
502
- if (!tokens.some(token => /^(?:F|M|R|RX)$/.test(token))) continue;
503
- const key = match[1].trim().toLowerCase();
504
- const entry = result.get(key) || { allow: false, deny: false };
505
- if (tokens.includes('DENY')) entry.deny = true;
506
- else entry.allow = true;
507
- result.set(key, entry);
508
- }
509
- return result;
510
- }
511
-
512
- function posixModeIsSecretSafe(mode) {
513
- return (mode & 0o077) === 0 && (mode & 0o600) === 0o600;
514
- }
515
-
516
- function validatePosixSecretFileAcl(configuredFilePath, filesystem) {
517
- const stat = filesystem.lstatSync(configuredFilePath);
518
- if (!posixModeIsSecretSafe(stat.mode)) {
519
- throw safeError('SECRET_FILE_ACL_UNSAFE');
520
- }
521
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
522
- throw safeError('SECRET_FILE_ACL_UNSAFE');
523
- }
524
473
  }
525
474
 
526
475
  function productionSourceBehavior(repositoryRoot) {
@@ -612,7 +561,6 @@ function safeError(category) {
612
561
  module.exports = {
613
562
  resolveApprovedLiveConfiguration,
614
563
  withApprovedLiveConfigurationTestComposition,
615
- posixModeIsSecretSafe,
616
564
  buildProfileConfiguration,
617
565
  APPROVED_EMBEDDING_VALUES: APPROVED,
618
566
  // The authoritative set of keys an approved `.env` file may carry. The