When I started building Aqiron Security, the natural approach was to keep the security logic inside the VS Code extension.
That works.
At least initially.
But as the project started accumulating scanner orchestration, finding normalization, correlation, project intelligence, RAG, reporting, and AI operations, I started running into a deeper architectural question:
Should the VS Code extension actually own the application's core runtime?
I decided the answer should be no.
Aqiron Security is an open-source application security project. The current product is a VS Code extension, but I wanted the security/runtime layer to have a much cleaner boundary from the client.
The result is a design where the extension acts as the client and starts a separate Node.js process containing the TypeScript core.
This article explains why I made that separation, how the current architecture works, and some of the trade-offs I have encountered.
The original problem
A VS Code extension has a lot of responsibilities already.
It deals with things like:
extension activation
commands
diagnostics
the VS Code API
webview communication
editor state
user settings
UI lifecycle
Security scanning introduces another class of responsibilities:
starting external tools
parsing scanner output
normalizing findings
correlating duplicate results
generating reports
managing long-running operations
cancellation
AI operations
workspace analysis
Putting all of that into one runtime makes the boundary blurry.
The code may still work, but over time the architecture starts answering questions like:
"Does this security service need VS Code?"
That is a dangerous dependency to create if the answer does not actually need to be yes.
The current Aqiron architecture looks roughly like this:
ββββββββββββββββββββββββββββββββββββββββββββββββ
β VS Code Extension β
β β
β activation / commands / diagnostics β
β React webview / settings / workspace UI β
β ScanController / client-side services β
ββββββββββββββββββββββββ¬ββββββββββββββββββββββββ
β
β CoreClient
β CoreProcessManager
β
β newline-delimited JSON
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Aqiron Core Runtime β
β Node.js + TypeScript β
β β
β protocol / cancellation / adapters β
β scanning / analysis / correlation β
β reports / RAG / AI operations β
ββββββββββββββββββββββββ¬ββββββββββββββββββββββββ
β
ββββββββββββββ΄βββββββββββββ
βΌ βΌ
Native Aqiron rules External scanners
Trivy / Semgrep
OSV-Scanner /
Betterleaks / MobSF
β β
ββββββββββββββ¬βββββββββββββ
βΌ
Unified findings
βΌ
correlation + graph
βΌ
reports
This is not a cloud architecture.
The current project is local-first. The extension starts the core process locally and communicates with it over standard input/output. The repository currently keeps packages/core private and bundles it into the extension rather than publishing it as a separate npm package.
That distinction is important.
The boundary exists today, but the eventual product packaging can evolve later.
There were several reasons.
The first reason is architectural independence.
The core should not need to know that VS Code exists.
Ideally, security logic should be able to operate on concepts like:
workspace
scan
finding
project
report
AI request
rather than:
vscode.workspace
vscode.window
WebviewPanel
TextDocument
DiagnosticCollection
The extension is responsible for translating between the developer environment and the core.
That gives me a much cleaner dependency direction:
VS Code client
β
Core
VS Code β Security logic β VS Code
Using a separate process also creates a runtime boundary.
The extension host and the security core no longer execute as one giant logical process.
That matters for long-running operations.
A scan might involve:
discover files
β
run multiple tools
β
parse results
β
normalize findings
β
correlate findings
β
build relationships
β
generate reports
That's a very different workload from handling an editor command or updating a sidebar.
With a separate core process, the extension can treat the security engine more like a service.
That makes lifecycle handling, restart behavior, and failure boundaries easier to reason about.
This was probably the most valuable part of the architecture.
Once the extension and core became separate processes, they couldn't casually call each other's internal functions anymore.
They needed a protocol.
The current protocol is intentionally simple:
stdin/stdout
newline-delimited JSON
A request looks conceptually like:
interface CoreRequestMessage {
id: string;
type: "request";
method: string;
params?: unknown;
}
And a response:
interface CoreResponseMessage {
id: string;
type: "response";
success: boolean;
result?: unknown;
error?: CoreProtocolError;
}
There are also event messages for asynchronous pipeline updates:
interface CoreEventMessage {
type: "event";
event: string;
requestId?: string;
payload?: unknown;
}
The actual protocol also has a versioned handshake.
For example, the current runtime exposes a protocol version and core version and can report compatibility states such as:
compatible
protocol-mismatch
extension-too-old
core-too-old
unsupported
That gives us an explicit compatibility boundary instead of relying on both sides silently assuming they agree.
I deliberately didn't start with something complicated.
The current transport is essentially:
message 1\n
message 2\n
message 3\n
where each line contains one JSON message.
That gives us a few useful properties:
You can literally look at the communication stream.
Malformed input can be identified and rejected.
The extension starts the process locally and communicates through stdio.
The wire format is JSON rather than TypeScript-specific objects.
That last point matters.
The implementation is TypeScript, but the protocol doesn't fundamentally need to be.
Once requests and responses cross a process boundary, we need a way to know which response belongs to which request.
That's why requests carry an ID.
For example:
request id: abc123
method: scan.start
The response can return:
id: abc123
success: true
Without this, concurrent operations become painful to reason about.
The ID becomes the connection between:
request
β
core operation
β
response
and also gives us something useful for cancellation and pipeline events.
Security operations shouldn't be treated as unstoppable functions.
Imagine starting a deep scan and then closing the workspace.
Or starting an AI analysis and then deciding you don't need it anymore.
The architecture therefore includes explicit cancellation operations.
The current core protocol exposes operations such as:
core.cancel
scan.cancel
ai.cancel
and the runtime propagates cancellation through the relevant cancellation sources and scanner context.
This is one of those details that seems unnecessary until you have a real long-running operation.
Then it becomes essential.
One of the goals of the boundary is to let the client ask for a scan without knowing the implementation details.
Conceptually:
await coreClient.startScan({
workspaceRoot,
mode: "deep",
trusted: true
});
The extension doesn't need to know:
which scanners are installed
how scanner output is parsed
how findings are normalized
how correlation works
how reports are generated
Those concerns belong to the core pipeline.
The current core pipeline can combine native Aqiron rules with optional external scanners, normalize results into UnifiedFinding[], then pass them through correlation/graph processing and report generation.
That separation is the main reason I like this architecture.
A scanner may produce one format.
Another scanner produces something completely different.
Scanner A
severity = HIGH
file = foo.dart
line = 41
while another might report:
Scanner B
level = error
path = foo.dart
startLine = 41
The core shouldn't force the rest of the system to understand every scanner's native format.
Instead, scanner-specific parsers convert the output into a common model.
external scanner
β
scanner-specific parser
β
UnifiedFinding
β
correlation
β
report
This is one of the biggest advantages of having an application-level core rather than scattering scanner logic throughout the VS Code extension.
This is important because architecture diagrams can easily make an early project look more mature than it actually is.
Aqiron is currently version 0.0.1 and under active development.
There are still intentional limitations.
workspace operations currently require a Flutter workspace
external scanners are optional
the core is still bundled into the extension
quick file scans use a separate direct extension path
the project does not yet have independently published Core, CLI, or Desktop packages
So the current architecture is not:
`Aqiron Core npm package
β
VS Code
CLI
Desktop
Not yet.
It's closer to:
VS Code
β
internal Core process
β
bundled runtime`
That is an important distinction.
The architecture is being prepared for broader reuse without prematurely creating a bunch of packages that don't yet need to exist.
Why I didn't immediately split everything into repositories
This was another deliberate decision.
It would be easy to say:
aqiron-core
aqiron-security-vscode
aqiron-security-cli
aqiron-security-desktop
and create four repositories immediately.
But that would add operational complexity before the products existed.
You would now have to manage:
package publishing
version coordination
cross-repository changes
release synchronization
dependency management
contributor workflow across multiple repositories
The current repository gives me a cleaner intermediate step:
src/
packages/core/
with an explicit runtime boundary.
When multiple clients become real products, the repository structure can change.
Until then, the architecture can evolve without forcing the project to pay the cost of premature distribution.
The biggest win isn't actually "using IPC."
The bigger win is making the boundary explicit.
The VS Code extension owns the developer environment.
The core owns security operations.
The protocol connects them.
That gives us a mental model like:
`Client responsibilities
β
UI
VS Code
commands
diagnostics
workspace interaction
β
β protocol
βΌ
Core responsibilities
β
scanning
normalization
correlation
RAG
AI operations
reports
`
That's much easier to reason about than a single giant extension runtime.
The architecture also introduces new problems.
A process boundary is not free.
Now we have to care about:
process startup time
restart behavior
malformed messages
protocol compatibility
stderr/stdout handling
partial failures
cancellation
shutdown
concurrent requests
serialization overhead
In other words:
We traded code coupling for process-boundary complexity.
I think that's a reasonable trade for Aqiron, but it isn't automatically the right choice for every VS Code extension.
If your project is a small command-based extension with a few hundred lines of logic, this architecture would probably be overkill.
For a growing security platform with multiple subsystems and long-running operations, the boundary becomes much more interesting.
The long-term idea is not "make a complicated VS Code extension."
It's to make the security core reusable.
The future might eventually look like:
Aqiron Core
/ | \
/ | \
β β β
VS Code CLI Desktop
But I don't need to build all three clients today.
Right now, I'm using the VS Code extension as the first real client and using the Core boundary to keep the architecture ready for future evolution.
That's the part I'm most interested in getting right.
The biggest lesson I've taken from this project is that architecture isn't about drawing the biggest possible diagram.
It's about deciding where responsibilities should stop.
For Aqiron, the important boundary became:
VS Code is the client.
The TypeScript runtime is the security engine.
IPC is the contract between them.
That doesn't mean the architecture is finished.
It means there is now a clear place to evolve it.
I'm still working through the trade-offs, so I'd be interested in hearing from people who have built:
VS Code extensions with external processes
TypeScript/Node developer tools
language-server-style architectures
security scanners
CLI + GUI products sharing a common runtime
Aqiron Security is open source and currently under active development.