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.
- package/README.md +127 -57
- package/argo/.env.example +181 -163
- package/argo/plugins/argo-wakeup.js +29 -27
- package/argo/rules/archgraph.instructions.md +189 -187
- package/argo/schema/schema-bundle.config.json +22 -0
- package/argo/schema/schema-bundle.rules.json +12229 -0
- package/argo/scripts/agentSearchDiagnose.js +410 -407
- package/argo/scripts/argo-mcp-server.js +122 -9
- package/argo/scripts/ensureArgoHarnessEnvironment.js +14 -0
- package/argo/scripts/external-graph-query.js +162 -0
- package/argo/scripts/graph-rag/defaultSemanticRetrieval.js +0 -13
- package/argo/scripts/graph-rag/liveEmbeddingProviderConfig.js +9 -61
- package/argo/scripts/graph-semantics.js +363 -227
- package/argo/scripts/runArchitectureTests.js +591 -583
- package/argo/scripts/schema-bundle.js +932 -0
- package/argo/scripts/systemarchitecture-mcp-server.js +758 -110
- package/argo/scripts/validateSystemArchitecture.js +262 -253
- package/argo/scripts/validator-mcp-server.js +1 -1
- package/argo/scripts/workspace-write-guard.js +144 -0
- package/argo/skills/argo-init/SKILL.md +7 -9
- package/argo/skills/ea-human-reconcile/SKILL.md +40 -39
- package/cordis.patch.yml +15 -6
- package/dsh-argo-wakeup/index.js +17 -17
- package/dsh-argo-workspace/index.js +227 -141
- package/install-argo.ps1 +336 -206
- package/package.json +57 -53
|
@@ -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
|
|
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: {
|
|
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
|