@geraldmaron/construct 1.4.1 → 1.4.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.
- package/bin/construct +2 -1
- package/bin/construct-postinstall.mjs +27 -2
- package/config/tag-vocabulary.json +264 -0
- package/lib/config/schema.mjs +1 -1
- package/lib/doctor/diagnosis.mjs +20 -0
- package/lib/doctor/index.mjs +5 -0
- package/lib/embed/worker.mjs +5 -0
- package/lib/env-config.mjs +10 -2
- package/lib/export-validate.mjs +34 -2
- package/lib/host/readiness.mjs +109 -0
- package/lib/host-disposition.mjs +10 -1
- package/lib/install/stage-project.mjs +41 -4
- package/lib/intake/prepare.mjs +2 -0
- package/lib/mcp/destructive-approval.mjs +57 -0
- package/lib/mcp/server.mjs +208 -34
- package/lib/mcp/tool-rate-limit.mjs +47 -0
- package/lib/mcp/tool-safety.mjs +94 -0
- package/lib/mcp/tool-surface-parity.mjs +60 -0
- package/lib/mcp/tools/orchestration-run.mjs +45 -7
- package/lib/mcp/tools/project.mjs +25 -8
- package/lib/mcp/tools/storage.mjs +9 -1
- package/lib/mcp/tools/web-search-governance.mjs +96 -0
- package/lib/mcp/tools/web-search.mjs +5 -76
- package/lib/mcp-platform-config.mjs +27 -18
- package/lib/oracle/daemon-entry.mjs +6 -0
- package/lib/orchestration/runtime.mjs +81 -42
- package/lib/orchestration/web-capability.mjs +59 -0
- package/lib/orchestration/worker.mjs +263 -19
- package/lib/output-quality.mjs +61 -2
- package/lib/path-policy.mjs +56 -0
- package/lib/providers/secret-audit-wiring.mjs +35 -18
- package/lib/providers/secret-resolver.mjs +28 -13
- package/lib/registry/catalog.mjs +6 -0
- package/lib/registry/loader.mjs +7 -1
- package/lib/registry/validate.mjs +3 -3
- package/lib/sandbox.mjs +1 -1
- package/lib/service-manager.mjs +59 -9
- package/lib/storage/admin.mjs +7 -2
- package/package.json +6 -1
- package/registry/agent-manifest.json +117 -0
- package/registry/capabilities.json +1880 -0
- package/schemas/brand-voice.schema.json +24 -0
- package/schemas/capability-registry.schema.json +72 -0
- package/schemas/certification-run.schema.json +130 -0
- package/schemas/demo-recording.schema.json +46 -0
- package/schemas/eval-dataset.schema.json +79 -0
- package/schemas/execution-capability-profile.schema.json +46 -0
- package/schemas/execution-policy.schema.json +114 -0
- package/schemas/improvement-proposal.schema.json +65 -0
- package/schemas/mcp-tool-output.schema.json +61 -0
- package/schemas/platform-capabilities.schema.json +83 -0
- package/schemas/project-config.schema.json +227 -0
- package/schemas/project-demo.schema.json +60 -0
- package/schemas/provider-behavior-matrix.schema.json +91 -0
- package/schemas/scope.schema.json +197 -0
- package/schemas/specialist-trace.schema.json +107 -0
- package/schemas/team.schema.json +99 -0
- package/schemas/unified-registry.schema.json +548 -0
- package/scripts/sync-specialists.mjs +52 -25
- package/specialists/org/specialists/cx-researcher.json +1 -0
- package/specialists/prompts/cx-researcher.md +9 -8
- package/vendor/pandoc-ext/README.md +3 -0
- package/vendor/pandoc-ext/diagram.lua +687 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mcp/tool-rate-limit.mjs — per-tool call-rate budget for the MCP CallTool path.
|
|
3
|
+
*
|
|
4
|
+
* Solo deployment mode never instantiates lib/mcp/broker.mjs's Broker (that class
|
|
5
|
+
* is role/policy-based and only wired in for team/enterprise), so the live MCP
|
|
6
|
+
* dispatch path has no bound on how often a single tool can be called — a stuck
|
|
7
|
+
* loop or an injected instruction can hammer a destructive tool until it exhausts
|
|
8
|
+
* its approval tokens, or burn cost/tokens spamming a read tool. checkToolRateLimit
|
|
9
|
+
* applies a sliding-window budget tiered by safety class, independent of role.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_WINDOW_MS = 60_000;
|
|
13
|
+
const DEFAULT_BUDGET_BY_CLASS = Object.freeze({
|
|
14
|
+
destructive: 5,
|
|
15
|
+
write: 60,
|
|
16
|
+
read: 300,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export class ToolRateLimited extends Error {
|
|
20
|
+
constructor(tool, budget, windowMs) {
|
|
21
|
+
super(`rate-limited: ${tool} exceeded ${budget} calls per ${Math.round(windowMs / 1000)}s`);
|
|
22
|
+
this.name = 'ToolRateLimited';
|
|
23
|
+
this.tool = tool;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ToolRateLimiter {
|
|
28
|
+
constructor({ budgetByClass = DEFAULT_BUDGET_BY_CLASS, windowMs = DEFAULT_WINDOW_MS, now = () => Date.now() } = {}) {
|
|
29
|
+
this.budgetByClass = budgetByClass;
|
|
30
|
+
this.windowMs = windowMs;
|
|
31
|
+
this.now = now;
|
|
32
|
+
this.calls = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Disabled when windowMs is falsy (0/NaN), mirroring the existing
|
|
36
|
+
// CONSTRUCT_MCP_TOOL_TIMEOUT_MS override convention in lib/mcp/server.mjs.
|
|
37
|
+
|
|
38
|
+
check(tool, safetyClass) {
|
|
39
|
+
if (!this.windowMs) return;
|
|
40
|
+
const budget = this.budgetByClass[safetyClass] ?? this.budgetByClass.read;
|
|
41
|
+
const ts = this.now();
|
|
42
|
+
const fresh = (this.calls.get(tool) || []).filter((t) => ts - t < this.windowMs);
|
|
43
|
+
if (fresh.length >= budget) throw new ToolRateLimited(tool, budget, this.windowMs);
|
|
44
|
+
fresh.push(ts);
|
|
45
|
+
this.calls.set(tool, fresh);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mcp/tool-safety.mjs — per-tool safety classification for the MCP catalog.
|
|
3
|
+
*
|
|
4
|
+
* One entry per tool name in lib/mcp/server.mjs's ALL_TOOL_DEFS (plus the `call`
|
|
5
|
+
* gateway). `class` is the host-facing risk tier; `filesystem`/`network`/`process`
|
|
6
|
+
* name which resource classes the tool can touch, derived from each tool's own
|
|
7
|
+
* description and input schema. withSafetyEnvelope (server.mjs) throws if a tool
|
|
8
|
+
* is missing here, so a newly added tool cannot ship without classification.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const READ = 'read';
|
|
12
|
+
const WRITE = 'write';
|
|
13
|
+
const DESTRUCTIVE = 'destructive';
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_OUTPUT_SCHEMA = Object.freeze({ type: 'object' });
|
|
16
|
+
|
|
17
|
+
export const TOOL_SAFETY = Object.freeze({
|
|
18
|
+
agent_health: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
19
|
+
summarize_diff: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
|
|
20
|
+
scan_file: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
21
|
+
extract_document_text: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
|
|
22
|
+
ingest_document: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
|
|
23
|
+
infer_document_schema: { class: WRITE, filesystem: 'write', network: 'fetch', process: 'spawn' },
|
|
24
|
+
list_schema_artifacts: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
25
|
+
storage_status: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
26
|
+
storage_sync: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
27
|
+
storage_reset: { class: DESTRUCTIVE, filesystem: 'write', network: 'none', process: 'none' },
|
|
28
|
+
delete_ingested_artifacts: { class: DESTRUCTIVE, filesystem: 'write', network: 'none', process: 'none' },
|
|
29
|
+
project_context: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
|
|
30
|
+
get_skill: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
31
|
+
orchestration_policy: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
32
|
+
list_skills: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
33
|
+
worker_run: { class: WRITE, filesystem: 'write', network: 'fetch', process: 'spawn' },
|
|
34
|
+
broker_check: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
35
|
+
agent_contract: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
36
|
+
find_tool: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
37
|
+
get_template: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
38
|
+
list_templates: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
39
|
+
search_skills: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
40
|
+
list_teams: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
41
|
+
suggest_skills: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
42
|
+
cx_trace: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
43
|
+
cx_score: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
44
|
+
cx_trace_update: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
45
|
+
session_list: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
46
|
+
session_load: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
47
|
+
session_search: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
48
|
+
session_save: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
49
|
+
memory_search: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
50
|
+
memory_add_observations: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
51
|
+
memory_create_entities: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
52
|
+
memory_recent: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
53
|
+
rovo_search: { class: READ, filesystem: 'none', network: 'fetch', process: 'none' },
|
|
54
|
+
efficiency_snapshot: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
55
|
+
session_usage: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
56
|
+
provider_fetch: { class: WRITE, filesystem: 'write', network: 'fetch', process: 'none' },
|
|
57
|
+
scope_show: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
58
|
+
scope_list: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
59
|
+
scope_drafts: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
60
|
+
scope_health: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
61
|
+
outcomes_summary: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
62
|
+
outcomes_record: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
63
|
+
knowledge_add: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
64
|
+
scope_create: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
65
|
+
scope_archive: { class: DESTRUCTIVE, filesystem: 'write', network: 'none', process: 'none' },
|
|
66
|
+
sandbox_list: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
67
|
+
knowledge_graph_ask: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
68
|
+
learning_status: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
69
|
+
document_export: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
|
|
70
|
+
publish_detect: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
|
|
71
|
+
publish_run: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
|
|
72
|
+
knowledge_search: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
73
|
+
workflow_init: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
74
|
+
workflow_add_task: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
75
|
+
workflow_update_task: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
76
|
+
workflow_needs_main_input: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
77
|
+
workflow_validate: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
78
|
+
workflow_status: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
79
|
+
workflow_contract_validate: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
80
|
+
workflow_import_plan: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
81
|
+
cx_trace_telemetry: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
82
|
+
artifact_workflow: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
|
|
83
|
+
author_artifact: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
|
|
84
|
+
model_resolve: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
85
|
+
triage_recommend: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
|
|
86
|
+
workflow_invoke: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
|
|
87
|
+
capability_describe: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
88
|
+
construct_execution_resolve: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
89
|
+
orchestration_run: { class: WRITE, filesystem: 'write', network: 'fetch', process: 'none' },
|
|
90
|
+
web_search: { class: READ, filesystem: 'none', network: 'fetch', process: 'none' },
|
|
91
|
+
orchestration_status: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
92
|
+
orchestration_readiness: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
|
|
93
|
+
call: { class: DESTRUCTIVE, filesystem: 'write', network: 'fetch', process: 'spawn' },
|
|
94
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mcp/tool-surface-parity.mjs — partition invariant for the MCP tool surface.
|
|
3
|
+
*
|
|
4
|
+
* The exposed surface is split into a flat core (CORE_TOOL_NAMES) and a long tail
|
|
5
|
+
* reached through the `call` gateway. Both are hand-maintained beside the catalog
|
|
6
|
+
* (ALL_TOOL_DEFS), so a typo in a core name silently drops a tool from BOTH the
|
|
7
|
+
* flat surface and the gateway enum, making it unreachable. These helpers enforce
|
|
8
|
+
* the partition from the catalog as the single source of truth: every declared
|
|
9
|
+
* core name must be a real catalog tool, and the flat core plus the gateway enum
|
|
10
|
+
* must together cover the catalog exactly once (no gap, no overlap, no phantom).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// A core name that is not in the catalog is a typo or a stale entry; it must be
|
|
14
|
+
// rejected loudly rather than silently dropping the tool from every surface.
|
|
15
|
+
|
|
16
|
+
export function assertCoreSubsetOfCatalog(coreNames, catalog) {
|
|
17
|
+
const catalogSet = catalog instanceof Set ? catalog : new Set(catalog);
|
|
18
|
+
const phantom = [...coreNames].filter((name) => !catalogSet.has(name));
|
|
19
|
+
if (phantom.length > 0) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`tool-surface-parity: ${phantom.length} core tool name(s) absent from the catalog: ${phantom.join(', ')} — `
|
|
22
|
+
+ 'a typo in CORE_TOOL_NAMES drops the tool from both the flat surface and the call enum.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The flat core and the gateway enum must partition the catalog exactly once.
|
|
29
|
+
// Returns the partition diagnostics; assertToolSurfacePartition throws on any break.
|
|
30
|
+
|
|
31
|
+
export function checkToolSurfacePartition({ catalog, flat, enumNames }) {
|
|
32
|
+
const catalogSet = catalog instanceof Set ? catalog : new Set(catalog);
|
|
33
|
+
const surfaced = [...flat, ...enumNames];
|
|
34
|
+
|
|
35
|
+
const overlap = flat.filter((name) => enumNames.includes(name));
|
|
36
|
+
const duplicates = [...new Set(surfaced.filter((name, idx) => surfaced.indexOf(name) !== idx))];
|
|
37
|
+
const missing = [...catalogSet].filter((name) => !flat.includes(name) && !enumNames.includes(name));
|
|
38
|
+
const phantom = [...new Set(surfaced.filter((name) => !catalogSet.has(name)))];
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
ok: overlap.length === 0 && duplicates.length === 0 && missing.length === 0 && phantom.length === 0,
|
|
42
|
+
overlap: overlap.sort(),
|
|
43
|
+
duplicates: duplicates.sort(),
|
|
44
|
+
missing: missing.sort(),
|
|
45
|
+
phantom: phantom.sort(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function assertToolSurfacePartition({ catalog, flat, enumNames }) {
|
|
50
|
+
const result = checkToolSurfacePartition({ catalog, flat, enumNames });
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
const parts = [];
|
|
53
|
+
if (result.overlap.length) parts.push(`flat AND enum: ${result.overlap.join(', ')}`);
|
|
54
|
+
if (result.duplicates.length) parts.push(`surfaced twice: ${result.duplicates.join(', ')}`);
|
|
55
|
+
if (result.missing.length) parts.push(`unreachable: ${result.missing.join(', ')}`);
|
|
56
|
+
if (result.phantom.length) parts.push(`not in catalog: ${result.phantom.join(', ')}`);
|
|
57
|
+
throw new Error(`tool-surface-parity: partition broken — ${parts.join('; ')}`);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
@@ -13,16 +13,20 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { runOrchestration, startRun, getRun, getRuns } from '../../orchestration/runtime.mjs';
|
|
16
|
+
import { governWebResults } from './web-search-governance.mjs';
|
|
16
17
|
|
|
17
18
|
const TERMINAL = ['completed', 'completed-with-failures', 'cancelled', 'error'];
|
|
18
19
|
|
|
19
20
|
function shapeRun(run) {
|
|
21
|
+
const allPrepared = Array.isArray(run.tasks) && run.tasks.length > 0 && run.tasks.every((t) => t.status === 'prepared');
|
|
20
22
|
return {
|
|
21
23
|
runId: run.runId,
|
|
22
|
-
status: run.status,
|
|
24
|
+
status: allPrepared ? 'completed-prepare-only' : run.status,
|
|
25
|
+
prepareOnly: allPrepared,
|
|
26
|
+
semantics: run.semantics ?? null,
|
|
23
27
|
executionMode: run.execution?.executionMode,
|
|
24
|
-
degraded: run.execution?.degraded ?? false,
|
|
25
|
-
degradationReason: run.execution?.degradationReason ?? null,
|
|
28
|
+
degraded: run.degraded ?? run.execution?.degraded ?? false,
|
|
29
|
+
degradationReason: run.degradationReason ?? run.execution?.degradationReason ?? null,
|
|
26
30
|
intent: run.plan?.intent ?? null,
|
|
27
31
|
track: run.plan?.track ?? null,
|
|
28
32
|
suggestedWorkflowType: run.plan?.suggestedWorkflowType ?? null,
|
|
@@ -31,10 +35,33 @@ function shapeRun(run) {
|
|
|
31
35
|
tasks: (run.tasks || []).map((t) => ({
|
|
32
36
|
id: t.id, role: t.role, status: t.status, executor: t.executor,
|
|
33
37
|
output: t.output ?? null, reasoning: t.reasoning ?? null, error: t.error ?? null,
|
|
38
|
+
// ADR-0050: which web grant reached the web, the F08-governed evidence, and search count.
|
|
39
|
+
webCapability: t.webCapability ?? null,
|
|
40
|
+
webEvidence: t.webEvidence ?? null,
|
|
41
|
+
webSearchRequests: t.webSearchRequests ?? 0,
|
|
34
42
|
})),
|
|
35
43
|
};
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
// Fail-closed remote ingress guard (ADR-0050): the remote orchestration service is
|
|
47
|
+
// out-of-repo and cannot be trusted to govern. Re-run every task's webEvidence through
|
|
48
|
+
// the single F08 grader so a citation can never arrive trusted or ungoverned, and mark the
|
|
49
|
+
// run degraded if any item was not already trust:'untrusted' with a valid Admiralty grade.
|
|
50
|
+
|
|
51
|
+
export function governRemoteWebEvidence(run, now = Date.now()) {
|
|
52
|
+
let tampered = false;
|
|
53
|
+
for (const t of run.tasks || []) {
|
|
54
|
+
if (!Array.isArray(t.webEvidence) || t.webEvidence.length === 0) continue;
|
|
55
|
+
if (t.webEvidence.some((e) => e?.trust !== 'untrusted' || !/^[A-F][1-6]$/.test(e?.admiralty || ''))) tampered = true;
|
|
56
|
+
t.webEvidence = governWebResults(t.webEvidence, { now });
|
|
57
|
+
}
|
|
58
|
+
if (tampered) {
|
|
59
|
+
run.degraded = true;
|
|
60
|
+
run.degradationReason = run.degradationReason || 'remote-web-evidence-regoverned';
|
|
61
|
+
}
|
|
62
|
+
return run;
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
function toRequest(args) {
|
|
39
66
|
return {
|
|
40
67
|
request: args.request,
|
|
@@ -108,13 +135,24 @@ export async function orchestrationStatus(args = {}, { env = process.env, cwd =
|
|
|
108
135
|
// is set; the wire shape matches the standalone orchestration service's
|
|
109
136
|
// /api/orchestration/runs contract (ADR-0022).
|
|
110
137
|
|
|
111
|
-
|
|
138
|
+
// Per-request AbortSignal so a single hung fetch cannot stall runViaService
|
|
139
|
+
// past a deterministic window even before the poll loop's overall deadline check.
|
|
140
|
+
|
|
141
|
+
function timedRemoteFetch(fetchImpl, url, opts, perRequestMs) {
|
|
142
|
+
const signal = AbortSignal.timeout(perRequestMs);
|
|
143
|
+
return Promise.race([
|
|
144
|
+
fetchImpl(url, { ...opts, signal }),
|
|
145
|
+
new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason ?? new Error(`remote orchestration fetch timed out after ${perRequestMs}ms`)), { once: true })),
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runViaService(remote, reqObj, { wait, timeout_ms, workerBackend, fetchImpl, perRequestTimeoutMs = 200 }) {
|
|
112
150
|
const { base, token } = remote;
|
|
113
151
|
const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
|
114
152
|
const body = JSON.stringify({ ...reqObj, workerBackend });
|
|
115
153
|
let started;
|
|
116
154
|
try {
|
|
117
|
-
const res = await fetchImpl
|
|
155
|
+
const res = await timedRemoteFetch(fetchImpl, `${base}/api/orchestration/runs`, { method: 'POST', headers, body }, perRequestTimeoutMs);
|
|
118
156
|
if (res.status === 401 || res.status === 403) {
|
|
119
157
|
return { error: `Remote service rejected the request (HTTP ${res.status}). Set CONSTRUCT_ORCHESTRATION_TOKEN (or CONSTRUCT_DASHBOARD_TOKEN).`, service: base, failFast: true };
|
|
120
158
|
}
|
|
@@ -137,7 +175,7 @@ async function runViaService(remote, reqObj, { wait, timeout_ms, workerBackend,
|
|
|
137
175
|
}
|
|
138
176
|
await new Promise((r) => setTimeout(r, 250));
|
|
139
177
|
try {
|
|
140
|
-
const res = await fetchImpl
|
|
178
|
+
const res = await timedRemoteFetch(fetchImpl, `${base}/api/orchestration/runs/${encodeURIComponent(started.runId)}`, { headers }, perRequestTimeoutMs);
|
|
141
179
|
const envelope = await res.json();
|
|
142
180
|
if (!res.ok) return { error: `Remote service error while polling (HTTP ${res.status}): ${envelope.error || 'unknown'}`, runId: started.runId, service: base };
|
|
143
181
|
run = envelope.data;
|
|
@@ -145,7 +183,7 @@ async function runViaService(remote, reqObj, { wait, timeout_ms, workerBackend,
|
|
|
145
183
|
return { error: `Lost connection to the remote service while polling run ${started.runId}: ${err.message}`, runId: started.runId, service: base, failFast: true };
|
|
146
184
|
}
|
|
147
185
|
}
|
|
148
|
-
return { ...shapeRun(run), service: base };
|
|
186
|
+
return { ...shapeRun(governRemoteWebEvidence(run)), service: base };
|
|
149
187
|
}
|
|
150
188
|
|
|
151
189
|
async function statusViaService(remote, { run_id, limit }, { fetchImpl }) {
|
|
@@ -8,7 +8,8 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
|
8
8
|
import { loadRegistry } from '../../registry/loader.mjs';
|
|
9
9
|
import { join, resolve } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { resolveWithinRoot } from '../../path-policy.mjs';
|
|
12
13
|
import { loadConstructEnv } from '../../env-config.mjs';
|
|
13
14
|
import { inspectContextState } from '../../context-state.mjs';
|
|
14
15
|
import { loadWorkflow, summarizeWorkflow, inspectWorkflowHealth } from '../../workflow-state.mjs';
|
|
@@ -16,8 +17,11 @@ import { resolveExecutionContractModelMetadata, selectModelTierForWorkCategory }
|
|
|
16
17
|
import { buildPublicHealthSurface } from '../../status.mjs';
|
|
17
18
|
import { doctorRoot } from '../../config/xdg.mjs';
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// Model-controlled refs reach these git calls. Run git through argv with no shell so a
|
|
21
|
+
// base_ref like `$(...)` is handed to git as one literal argument, never evaluated.
|
|
22
|
+
|
|
23
|
+
function runGit(argv, cwd) {
|
|
24
|
+
return execFileSync('git', argv, { stdio: 'pipe', timeout: 10000, cwd }).toString().trim();
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function readJSON(filePath) {
|
|
@@ -140,10 +144,16 @@ export function summarizeDiff(args) {
|
|
|
140
144
|
const baseRef = args.base_ref ?? 'HEAD~1';
|
|
141
145
|
const cwd = args.cwd ? resolve(args.cwd) : process.cwd();
|
|
142
146
|
|
|
147
|
+
// A ref that starts with `-` would be read by git as an option, not a revision;
|
|
148
|
+
// control characters never belong in a ref. Reject both before argv handoff.
|
|
149
|
+
if (typeof baseRef !== 'string' || baseRef.startsWith('-') || /[\0\n\r]/.test(baseRef)) {
|
|
150
|
+
return { error: 'Invalid base_ref' };
|
|
151
|
+
}
|
|
152
|
+
|
|
143
153
|
let stat, nameStatus;
|
|
144
154
|
try {
|
|
145
|
-
stat =
|
|
146
|
-
nameStatus =
|
|
155
|
+
stat = runGit(['diff', '--stat', baseRef], cwd);
|
|
156
|
+
nameStatus = runGit(['diff', '--name-status', baseRef], cwd);
|
|
147
157
|
} catch {
|
|
148
158
|
return { error: 'Not a git repository or git not available' };
|
|
149
159
|
}
|
|
@@ -184,7 +194,14 @@ export function summarizeDiff(args) {
|
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
export function scanFile(args) {
|
|
187
|
-
const
|
|
197
|
+
const root = args.cwd ? resolve(args.cwd) : process.cwd();
|
|
198
|
+
|
|
199
|
+
let filePath;
|
|
200
|
+
try {
|
|
201
|
+
filePath = resolveWithinRoot(root, args.file_path);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { error: err.message };
|
|
204
|
+
}
|
|
188
205
|
|
|
189
206
|
let rawBuffer;
|
|
190
207
|
try {
|
|
@@ -302,14 +319,14 @@ export function projectContext(args, { ROOT_DIR }) {
|
|
|
302
319
|
let changed_files = [];
|
|
303
320
|
|
|
304
321
|
try {
|
|
305
|
-
const log =
|
|
322
|
+
const log = runGit(['log', '--oneline', '-10'], cwd);
|
|
306
323
|
recent_commits = log.split('\n').filter(Boolean);
|
|
307
324
|
} catch {
|
|
308
325
|
// not a git repo or no commits
|
|
309
326
|
}
|
|
310
327
|
|
|
311
328
|
try {
|
|
312
|
-
const status =
|
|
329
|
+
const status = runGit(['status', '--short'], cwd);
|
|
313
330
|
const statusLines = status.split('\n').filter(Boolean);
|
|
314
331
|
changed_files = statusLines.map((l) => l.trim());
|
|
315
332
|
working_tree_status = statusLines.length > 0 ? 'dirty' : 'clean';
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* lib/mcp/tools/storage.mjs — Storage MCP tools: status, sync, reset, and artifact deletion.
|
|
3
3
|
*
|
|
4
4
|
* All functions are async. Wraps lib/storage/admin.mjs and lib/storage/sync.mjs.
|
|
5
|
-
*
|
|
5
|
+
* Destructive operations (reset, delete) require confirm=true plus a one-time out-of-band
|
|
6
|
+
* approval token, since these tools are reachable only through the model's argument channel.
|
|
6
7
|
*/
|
|
7
8
|
import { resolve } from 'node:path';
|
|
8
9
|
import { deleteIngestedArtifacts, getStorageStatus, inferProjectName, purgeExpiredData, resetStorage } from '../../storage/admin.mjs';
|
|
9
10
|
import { syncFileStateToSql } from '../../storage/sync.mjs';
|
|
11
|
+
import { consumeApprovalToken } from '../destructive-approval.mjs';
|
|
10
12
|
|
|
11
13
|
export async function storageStatus(args) {
|
|
12
14
|
const cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
|
|
@@ -34,6 +36,9 @@ export async function storageReset(args) {
|
|
|
34
36
|
if (args.confirm !== true) {
|
|
35
37
|
return { error: 'storage_reset requires confirm=true' };
|
|
36
38
|
}
|
|
39
|
+
if (!consumeApprovalToken('storage_reset', args.approval_token)) {
|
|
40
|
+
return { error: 'storage_reset requires a valid out-of-band approval token; confirm=true alone is not authorization' };
|
|
41
|
+
}
|
|
37
42
|
return resetStorage(cwd, {
|
|
38
43
|
env: process.env,
|
|
39
44
|
project,
|
|
@@ -48,6 +53,9 @@ export async function deleteIngestedArtifactsTool(args) {
|
|
|
48
53
|
if (args.confirm !== true) {
|
|
49
54
|
return { error: 'delete_ingested_artifacts requires confirm=true' };
|
|
50
55
|
}
|
|
56
|
+
if (!consumeApprovalToken('delete_ingested_artifacts', args.approval_token)) {
|
|
57
|
+
return { error: 'delete_ingested_artifacts requires a valid out-of-band approval token; confirm=true alone is not authorization' };
|
|
58
|
+
}
|
|
51
59
|
const files = Array.isArray(args.files)
|
|
52
60
|
? args.files.map((value) => String(value))
|
|
53
61
|
: [];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mcp/tools/web-search-governance.mjs — the single F08 grader for web evidence.
|
|
3
|
+
*
|
|
4
|
+
* Every web result any specialist or tool ever sees passes through governWebResults here,
|
|
5
|
+
* so the F08 governance invariants (ADR-0017) are produced in exactly one place: a
|
|
6
|
+
* claim-relative class, an Admiralty grade defaulting to C3 with a derived confidence,
|
|
7
|
+
* a normalized date with a 12-month staleness flag, most-recent-first ordering, and the
|
|
8
|
+
* OWASP-LLM01 `trust: 'untrusted'` stamp on externally-sourced text. Results without a
|
|
9
|
+
* verifiable http(s) URL are dropped, never guessed. web-search.mjs (the governed
|
|
10
|
+
* provider tool), the orchestration worker's provider-native web path, and the remote
|
|
11
|
+
* orchestration ingress guard all re-use this grader so a citation can never reach a
|
|
12
|
+
* specialist ungoverned or mislabeled as trusted.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const COMMUNITY_HOSTS = ['reddit.com', 'stackoverflow.com', 'news.ycombinator.com', 'discord.com', 'github.com'];
|
|
16
|
+
const HIGH_CONFIDENCE_GRADES = new Set(['A1', 'A2', 'B1']);
|
|
17
|
+
const STALE_AFTER_DAYS = 365;
|
|
18
|
+
|
|
19
|
+
export function normalizeDate(it) {
|
|
20
|
+
const raw = it.date || it.publishedAt || it.published || it.published_at || it.datePublished || null;
|
|
21
|
+
if (!raw) return null;
|
|
22
|
+
const t = Date.parse(raw);
|
|
23
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString().slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Claim-relative class (ADR-0017): community content is admissible primary evidence for
|
|
27
|
+
// sentiment/demand claims under a checklist the tool cannot verify deterministically, so
|
|
28
|
+
// community hosts default to `tertiary` (conservative for factual claims) and everything
|
|
29
|
+
// else to `secondary`, leaving the researcher to promote a community source when the claim
|
|
30
|
+
// is about experience.
|
|
31
|
+
|
|
32
|
+
export function classifyResult(url) {
|
|
33
|
+
try {
|
|
34
|
+
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
35
|
+
return COMMUNITY_HOSTS.some((h) => host === h || host.endsWith('.' + h)) ? 'tertiary' : 'secondary';
|
|
36
|
+
} catch {
|
|
37
|
+
return 'tertiary';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Confidence maps from the Admiralty grade; `high` is reserved for A1/A2/B1 (ADR-0017). A
|
|
42
|
+
// grade with reliability A/B or credibility 1/2 is `medium`; anything weaker is `low`.
|
|
43
|
+
|
|
44
|
+
export function confidenceFromGrade(grade) {
|
|
45
|
+
if (HIGH_CONFIDENCE_GRADES.has(grade)) return 'high';
|
|
46
|
+
const reliability = grade?.[0];
|
|
47
|
+
const credibility = grade?.[1];
|
|
48
|
+
if (reliability === 'A' || reliability === 'B' || credibility === '1' || credibility === '2') return 'medium';
|
|
49
|
+
return 'low';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Grade one raw item into a governed web result, or null when it carries no verifiable
|
|
53
|
+
// http(s) URL. A raw item may come from a governed provider (may carry its own admiralty)
|
|
54
|
+
// or a provider-native citation (never does — defaults to C3 + needsGrading).
|
|
55
|
+
|
|
56
|
+
export function governWebResult(it, { now = Date.now() } = {}) {
|
|
57
|
+
if (!it || typeof it.url !== 'string' || !/^https?:\/\//.test(it.url)) return null;
|
|
58
|
+
const graded = typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty);
|
|
59
|
+
const admiralty = graded ? it.admiralty : 'C3';
|
|
60
|
+
const date = normalizeDate(it);
|
|
61
|
+
const stale = date != null && (now - Date.parse(date)) > STALE_AFTER_DAYS * 86_400_000;
|
|
62
|
+
return {
|
|
63
|
+
source: 'web',
|
|
64
|
+
url: it.url,
|
|
65
|
+
title: typeof it.title === 'string' && it.title ? it.title : it.url,
|
|
66
|
+
snippet: typeof it.snippet === 'string' ? it.snippet : '',
|
|
67
|
+
class: classifyResult(it.url),
|
|
68
|
+
admiralty,
|
|
69
|
+
confidence: confidenceFromGrade(admiralty),
|
|
70
|
+
date,
|
|
71
|
+
stale,
|
|
72
|
+
needsGrading: !graded,
|
|
73
|
+
// OWASP LLM01: web snippets are externally-sourced data — must not be treated as instructions
|
|
74
|
+
trust: 'untrusted',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Order most-recent-first: fresh dated results, then undated (input order kept by the
|
|
79
|
+
// stable sort), then stale ones; newest first within a tier.
|
|
80
|
+
|
|
81
|
+
function recencyTier(r) {
|
|
82
|
+
return r.date && !r.stale ? 0 : r.date ? 2 : 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function governWebResults(items, { now = Date.now() } = {}) {
|
|
86
|
+
const results = (Array.isArray(items) ? items : [])
|
|
87
|
+
.map((it) => governWebResult(it, { now }))
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
results.sort((a, b) => {
|
|
90
|
+
const t = recencyTier(a) - recencyTier(b);
|
|
91
|
+
if (t !== 0) return t;
|
|
92
|
+
if (a.date && b.date) return Date.parse(b.date) - Date.parse(a.date);
|
|
93
|
+
return 0;
|
|
94
|
+
});
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
@@ -8,29 +8,12 @@
|
|
|
8
8
|
* Admiralty grade with a derived confidence (ADR-0017) — results without a URL are dropped, not
|
|
9
9
|
* guessed; (2) every result is labeled `source: 'web'` and the tool never falls back to repo/source
|
|
10
10
|
* search; (3) when no governed provider is configured or the provider is unreachable, the tool
|
|
11
|
-
* returns a typed degradation and zero results rather than claiming it searched the web.
|
|
11
|
+
* returns a typed degradation and zero results rather than claiming it searched the web. The grading
|
|
12
|
+
* itself lives in web-search-governance.mjs, the single chokepoint shared with the orchestration
|
|
13
|
+
* worker's provider-native web path (ADR-0050).
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
const HIGH_CONFIDENCE_GRADES = new Set(['A1', 'A2', 'B1']);
|
|
16
|
-
|
|
17
|
-
// Recency enforcement (rules/common/research.md §1, research-execution-policy
|
|
18
|
-
// recencyRule). The researcher is told to search most-recent-first and treat
|
|
19
|
-
// sources older than 12 months as stale, but the provider returns relevance- and
|
|
20
|
-
// authority-ranked results with the date dropped, so older established sources
|
|
21
|
-
// surface over newer ones. Normalize a date from whatever field the provider
|
|
22
|
-
// supplies, flag staleness against the 12-month window, and reorder
|
|
23
|
-
// most-recent-first. Admiralty-derived `confidence` is left intact (ADR-0017);
|
|
24
|
-
// recency rides alongside it as `date` + `stale` rather than folded into it.
|
|
25
|
-
|
|
26
|
-
const STALE_AFTER_DAYS = 365;
|
|
27
|
-
|
|
28
|
-
function normalizeDate(it) {
|
|
29
|
-
const raw = it.date || it.publishedAt || it.published || it.published_at || it.datePublished || null;
|
|
30
|
-
if (!raw) return null;
|
|
31
|
-
const t = Date.parse(raw);
|
|
32
|
-
return Number.isNaN(t) ? null : new Date(t).toISOString().slice(0, 10);
|
|
33
|
-
}
|
|
16
|
+
import { governWebResults } from './web-search-governance.mjs';
|
|
34
17
|
|
|
35
18
|
function providerConfig(env) {
|
|
36
19
|
const url = env.WEB_SEARCH_URL;
|
|
@@ -38,31 +21,6 @@ function providerConfig(env) {
|
|
|
38
21
|
return { url, key: env.WEB_SEARCH_KEY || null };
|
|
39
22
|
}
|
|
40
23
|
|
|
41
|
-
// Claim-relative class (ADR-0017): community content is admissible primary evidence for sentiment/
|
|
42
|
-
// demand claims under a checklist the tool cannot verify deterministically, so it defaults community
|
|
43
|
-
// hosts to `tertiary` (conservative for factual claims) and everything else to `secondary`, leaving
|
|
44
|
-
// the researcher to promote a community source to primary when the claim is about experience.
|
|
45
|
-
|
|
46
|
-
function classifyResult(url) {
|
|
47
|
-
try {
|
|
48
|
-
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
49
|
-
return COMMUNITY_HOSTS.some((h) => host === h || host.endsWith('.' + h)) ? 'tertiary' : 'secondary';
|
|
50
|
-
} catch {
|
|
51
|
-
return 'tertiary';
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Confidence maps from the Admiralty grade; `high` is reserved for A1/A2/B1 (ADR-0017). A grade with
|
|
56
|
-
// reliability A/B or credibility 1/2 is `medium`; anything weaker is `low`.
|
|
57
|
-
|
|
58
|
-
function confidenceFromGrade(grade) {
|
|
59
|
-
if (HIGH_CONFIDENCE_GRADES.has(grade)) return 'high';
|
|
60
|
-
const reliability = grade?.[0];
|
|
61
|
-
const credibility = grade?.[1];
|
|
62
|
-
if (reliability === 'A' || reliability === 'B' || credibility === '1' || credibility === '2') return 'medium';
|
|
63
|
-
return 'low';
|
|
64
|
-
}
|
|
65
|
-
|
|
66
24
|
function degraded(reason, note) {
|
|
67
25
|
return { source: 'web', degraded: true, degradationReason: reason, results: [], note };
|
|
68
26
|
}
|
|
@@ -92,36 +50,7 @@ export async function webSearch(args = {}, { env = process.env, fetchImpl = glob
|
|
|
92
50
|
}
|
|
93
51
|
|
|
94
52
|
const items = Array.isArray(payload?.results) ? payload.results : [];
|
|
95
|
-
const results = items
|
|
96
|
-
.filter((it) => it && typeof it.url === 'string' && /^https?:\/\//.test(it.url))
|
|
97
|
-
.map((it) => {
|
|
98
|
-
const admiralty = typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty) ? it.admiralty : 'C3';
|
|
99
|
-
const date = normalizeDate(it);
|
|
100
|
-
const stale = date != null && (now - Date.parse(date)) > STALE_AFTER_DAYS * 86_400_000;
|
|
101
|
-
return {
|
|
102
|
-
source: 'web',
|
|
103
|
-
url: it.url,
|
|
104
|
-
title: typeof it.title === 'string' && it.title ? it.title : it.url,
|
|
105
|
-
snippet: typeof it.snippet === 'string' ? it.snippet : '',
|
|
106
|
-
class: classifyResult(it.url),
|
|
107
|
-
admiralty,
|
|
108
|
-
confidence: confidenceFromGrade(admiralty),
|
|
109
|
-
date,
|
|
110
|
-
stale,
|
|
111
|
-
needsGrading: !(typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty)),
|
|
112
|
-
};
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// Order most-recent-first: fresh dated results, then undated (provider order
|
|
116
|
-
// kept by the stable sort), then stale ones; newest first within a tier.
|
|
117
|
-
|
|
118
|
-
const tier = (r) => (r.date && !r.stale ? 0 : r.date ? 2 : 1);
|
|
119
|
-
results.sort((a, b) => {
|
|
120
|
-
const t = tier(a) - tier(b);
|
|
121
|
-
if (t !== 0) return t;
|
|
122
|
-
if (a.date && b.date) return Date.parse(b.date) - Date.parse(a.date);
|
|
123
|
-
return 0;
|
|
124
|
-
});
|
|
53
|
+
const results = governWebResults(items, { now });
|
|
125
54
|
|
|
126
55
|
return { source: 'web', degraded: false, query, claim, results, dropped: items.length - results.length };
|
|
127
56
|
}
|