
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 separation
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.
Why a separate process?
There were several reasons.
1. Keep the core independent from VS Code
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
rather than:
VS Code ↔ Security logic ↔ VS Code
2. Process isolation gives us a real boundary
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.
3. IPC forces us to define a contract
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.
Why newline-delimited JSON?
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:
Easy to inspect
You can literally look at the communication stream.
Easy to debug
Malformed input can be identified and rejected.
No additional server required
The extension starts the process locally and communicates through stdio.
Language-neutral at the protocol level
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.
Correlation IDs become important
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.
Cancellation is part of the architecture
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.
The extension should not know how scanning works
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.
The finding model becomes the shared language
A scanner may produce one format.
Another scanner produces something completely different.
For example:
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.
Conceptually:
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.
The architecture isn't completely finished
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.
For example:
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.
What I like about this architecture
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.
What I still need to watch
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 bigger goal
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.
Final thoughts
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
How would you design this boundary differently?
Aqiron Security is open source and currently under active development.
Top comments (3)
The versioned handshake and request IDs make this much more than a cosmetic package split. I especially like keeping scanner normalization in the core while the extension owns editor state. For a scan interrupted by a core-process crash or a partial NDJSON line, do you mark the request outcome as unknown and restart with a new ID, or resume from a persisted scan state? That failure boundary seems like a valuable integration test for the protocol.
That's exactly the failure boundary I'm thinking about.
Right now, Aqiron does not persist scan state for crash recovery. Scan state is currently kept in memory inside the Core runtime, so if the Core process dies during a scan, the in-flight request cannot be resumed after restart. A restarted Core starts with a fresh runtime state, and a new scan gets a new request/scan ID.
For the NDJSON side, the protocol treats each newline-delimited message as a complete JSON request; malformed JSON is rejected rather than partially interpreted.
I agree that this deserves a dedicated integration test. I'd like to test at least:
Core crash during an active request → pending request becomes a terminal failure/unknown state
Core restart → handshake succeeds again
Retry → new request ID
Partial/malformed NDJSON → rejected without corrupting subsequent messages
Persisted scan resume is something I'd consider later, but I don't want to introduce durable scan state until there's a concrete recovery model to justify it.
That failure matrix is actually a useful way to drive the protocol design forward.
That failure boundary was a good catch. I dug into the implementation and found that the basic crash handling was already there: pending requests are rejected with a Core-crash error and the process is automatically restarted.
The subtle issue was with the NDJSON buffering. The client buffers partial stdout chunks, but that buffer represents a Core process session. A partial line left by a crashed process could have survived into the restarted process.
I fixed that by resetting the stdout buffer at the process-session boundary and added regression tests for:
I’m intentionally not adding persisted scan resume yet. For now, a crash makes the in-flight operation terminal rather than pretending we can safely resume it.
That integration-test case ended up being useful — thanks for pointing it out.