-
Notifications
You must be signed in to change notification settings - Fork 0
D3-CR-F1 — Harden Cockpit host import scanner #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,47 @@ function hostSources(): readonly { readonly file: string; readonly text: string | |
| .map((name) => ({ file: name, text: readFileSync(join(hostDir, name), 'utf8') })); | ||
| } | ||
|
|
||
| /** | ||
| * Extract every module specifier from TypeScript/NodeNext ESM source. | ||
| * | ||
| * The import-discipline checks below judge *specifiers*, so a specifier this | ||
| * helper fails to surface is silently exempt from the boundary. It therefore | ||
| * covers all the static-graph import forms this project can use, in either | ||
| * quote style, so no forbidden dependency can hide behind syntax: | ||
| * | ||
| * - static: `import x from '...'` / `import x from "..."` | ||
| * (including multi-line `import type { ... } from '...'`) | ||
| * - side-effect: `import '...'` / `import "..."` | ||
| * - dynamic: `import('...')` / `import("...")` | ||
| * - re-export: `export { x } from '...'` / `export * from "..."` | ||
| * | ||
| * `import.meta.url` is deliberately not matched: the `import` keyword must be | ||
| * followed by whitespace (static/side-effect) or `(` (dynamic), and `.` is | ||
| * neither. This is a bounded lexical scan, not a parser — no AST dependency is | ||
| * introduced, and, like the original scanner, it does not exclude specifiers | ||
| * that appear inside comments. | ||
| */ | ||
| function extractModuleSpecifiers(source: string): readonly string[] { | ||
| const patterns: readonly RegExp[] = [ | ||
| // static (`import x from 'S'`) and side-effect (`import 'S'`) imports. | ||
| /\bimport\s+(?:[^'"]*?\bfrom\s+)?['"]([^'"]+)['"]/g, | ||
| // dynamic imports: `import('S')`. | ||
| /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, | ||
| // re-export bindings: `export { x } from 'S'`, `export * from 'S'`. | ||
| /\bexport\b[^'"]*?\bfrom\s+['"]([^'"]+)['"]/g, | ||
|
Comment on lines
+49
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Confirm the declared TypeScript version and effective module settings before
# selecting a TypeScript compiler API for the scanner.
fd -HI -t f --glob 'package.json' --glob 'tsconfig*.json' \
--glob '.nvmrc' --glob '.node-version' --glob '.tool-versions' |
while IFS= read -r file; do
echo "=== $file ==="
rg -n -C 2 '"typescript"|"module"|"moduleResolution"|"target"|node' "$file" || true
doneRepository: LogicDuke/agentbridge Length of output: 435 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '=== candidate configuration files ==='
git ls-files | rg '(^|/)(package\.json|tsconfig[^/]*\.json|\.nvmrc|\.node-version|\.tool-versions)$' || true
echo '=== purity test structure ==='
test -f tests/cockpit-host/purity.test.ts
wc -l tests/cockpit-host/purity.test.ts
ast-grep outline tests/cockpit-host/purity.test.ts --lang typescript || true
echo '=== scanner and assertions ==='
sed -n '1,140p' tests/cockpit-host/purity.test.ts
echo '=== relevant configuration contents ==='
while IFS= read -r file; do
echo "=== $file ==="
cat "$file"
done < <(git ls-files | rg '(^|/)(package\.json|tsconfig[^/]*\.json|\.nvmrc|\.node-version|\.tool-versions)$')Repository: LogicDuke/agentbridge Length of output: 6943 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '=== remaining purity test ==='
sed -n '135,180p' tests/cockpit-host/purity.test.ts
echo '=== package and TypeScript configuration ==='
cat package.json
printf '\n=== tsconfig.json ===\n'
cat tsconfig.json
printf '\n=== tsconfig.build.json ===\n'
cat tsconfig.build.json
echo '=== TypeScript references and compiler availability ==='
rg -n -C 2 '"typescript"|"module"|"moduleResolution"|"target"|importAttributes|verbatimModuleSyntax' \
package.json tsconfig.json tsconfig.build.json || true
command -v node || true
node --version 2>/dev/null || trueRepository: LogicDuke/agentbridge Length of output: 3507 🏁 Script executed: #!/bin/bash
set -euo pipefail
node - <<'JS'
const fs = require('node:fs');
const source = fs.readFileSync('tests/cockpit-host/purity.test.ts', 'utf8');
const patterns = [
/\bimport\s+(?:[^'"]*?\bfrom\s+)?['"]([^'"]+)['"]/g,
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
/\bexport\b[^'"]*?\bfrom\s+['"]([^'"]+)['"]/g,
];
function extract(text) {
return patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((m) => m[1]));
}
const cases = {
'dynamic import with comment': "import(/* note */ '../domain/foo.js')",
'static import with quoted comment': "import /* \"note\" */ x from '../domain/foo.js'",
'static import with unquoted comment': "import /* note */ x from '../domain/foo.js'",
're-export with comment': "export /* note */ { x } from '../domain/foo.js'",
'dynamic import with options': "import('../domain/foo.js', { with: { type: 'json' } })",
'dynamic import with webpack-style options': "import('../domain/foo.js', { webpackChunkName: 'foo' })",
};
for (const [name, text] of Object.entries(cases)) {
console.log(`${name}:`, JSON.stringify(extract(text)));
}
JSRepository: LogicDuke/agentbridge Length of output: 410 🏁 Script executed: #!/bin/bash
set -euo pipefail
node - <<'JS'
const patterns = [
/\bimport\s+(?:[^'"]*?\bfrom\s+)?['"]([^'"]+)['"]/g,
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
/\bexport\b[^'"]*?\bfrom\s+['"]([^'"]+)['"]/g,
];
function extract(source) {
return patterns.flatMap((pattern) => [...source.matchAll(pattern)].map((match) => match[1]));
}
const cases = [
"import /* 'note' */ x from '../domain/foo.js';",
'import /* "note" */ x from "../domain/foo.js";',
"export /* 'note' */ { x } from '../domain/foo.js';",
'export /* "note" */ { x } from "../domain/foo.js";',
"import(/* note */ '../domain/foo.js')",
"import('../domain/foo.js', { with: { type: 'json' } })",
];
for (const source of cases) {
console.log(JSON.stringify(source), '=>', JSON.stringify(extract(source)));
}
JSRepository: LogicDuke/agentbridge Length of output: 504 Make the ESM scanner token-aware.
🤖 Prompt for AI Agents |
||
| ]; | ||
| const specifiers: string[] = []; | ||
| for (const pattern of patterns) { | ||
| for (const match of source.matchAll(pattern)) { | ||
| const specifier = match[1]; | ||
| if (specifier !== undefined) { | ||
| specifiers.push(specifier); | ||
| } | ||
| } | ||
| } | ||
| return specifiers; | ||
| } | ||
|
|
||
| describe('D3 host has no mutation, subprocess, secret, or Git capability', () => { | ||
| it('references no subprocess, environment, or Git operation', () => { | ||
| const forbidden: readonly RegExp[] = [ | ||
|
|
@@ -45,12 +86,9 @@ describe('D3 host has no mutation, subprocess, secret, or Git capability', () => | |
| }); | ||
|
|
||
| describe('D3 host import discipline', () => { | ||
| const importSpecifiers = /from\s+'([^']+)'/g; | ||
|
|
||
| it('imports only node builtins, itself, or the Cockpit boundary', () => { | ||
| for (const { file, text } of hostSources()) { | ||
| for (const match of text.matchAll(importSpecifiers)) { | ||
| const specifier = match[1] ?? ''; | ||
| for (const specifier of extractModuleSpecifiers(text)) { | ||
| const allowed = | ||
| specifier.startsWith('node:') || | ||
| specifier.startsWith('./') || | ||
|
|
@@ -62,8 +100,7 @@ describe('D3 host import discipline', () => { | |
|
|
||
| it('never imports an adapter, transport, or authority module (nor the domain kernel directly)', () => { | ||
| for (const { file, text } of hostSources()) { | ||
| for (const match of text.matchAll(importSpecifiers)) { | ||
| const specifier = match[1] ?? ''; | ||
| for (const specifier of extractModuleSpecifiers(text)) { | ||
| expect( | ||
| /adapter|transport|authorization|repair-job|permit|\.\.\/domain\//i.test(specifier), | ||
| `${file} imports forbidden module: ${specifier}`, | ||
|
|
@@ -72,3 +109,49 @@ describe('D3 host import discipline', () => { | |
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe('D3 host import scanner recognizes every supported ESM form (D3-CR-F1)', () => { | ||
| // A forbidden domain/adapter import must be surfaced no matter which valid | ||
| // import syntax hides it — otherwise the discipline checks above are blind to | ||
| // it. Each fixture below is a single valid TypeScript/NodeNext ESM statement. | ||
| const forbiddenForms: readonly { readonly form: string; readonly source: string }[] = [ | ||
| { form: 'single-quoted static from', source: `import x from '../domain/foo.js';` }, | ||
| { form: 'double-quoted static from', source: `import x from "../domain/foo.js";` }, | ||
| { form: 'single-quoted side-effect', source: `import '../adapters/foo.js';` }, | ||
| { form: 'double-quoted side-effect', source: `import "../adapters/foo.js";` }, | ||
| { form: 'single-quoted dynamic', source: `const m = import('../domain/foo.js');` }, | ||
| { form: 'double-quoted dynamic', source: `const m = import("../domain/foo.js");` }, | ||
| { form: 're-export from', source: `export { y } from "../domain/foo.js";` }, | ||
| ]; | ||
|
|
||
| for (const { form, source } of forbiddenForms) { | ||
| it(`extracts the forbidden specifier from a ${form} import`, () => { | ||
| const specifiers = extractModuleSpecifiers(source); | ||
| const forbidden = specifiers.filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); | ||
| expect(forbidden.length, `no specifier extracted from: ${source}`).toBeGreaterThan(0); | ||
| }); | ||
| } | ||
|
|
||
| it('extracts allowed node builtin, local, and Cockpit-boundary specifiers', () => { | ||
| expect(extractModuleSpecifiers(`import http from 'node:http';`)).toContain('node:http'); | ||
| expect(extractModuleSpecifiers(`import { a } from "./local.js";`)).toContain('./local.js'); | ||
| expect( | ||
| extractModuleSpecifiers(`import { readCockpitSnapshot } from '../cockpit/index.js';`), | ||
| ).toContain('../cockpit/index.js'); | ||
| }); | ||
|
|
||
| it('extracts a multi-line `import type { ... } from` specifier', () => { | ||
| const source = [ | ||
| 'import type {', | ||
| ' CockpitSnapshot,', | ||
| ' CockpitFindingReadModel,', | ||
| "} from '../cockpit/index.js';", | ||
| ].join('\n'); | ||
| expect(extractModuleSpecifiers(source)).toContain('../cockpit/index.js'); | ||
| }); | ||
|
|
||
| it('does not treat `import.meta.url` as a module specifier', () => { | ||
| const source = `const isEntry = import.meta.url === pathToFileURL(entry).href;`; | ||
| expect(extractModuleSpecifiers(source)).toEqual([]); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a host file uses the standard two-argument dynamic-import syntax, such as
import('../domain/foo.js', { with: { type: 'json' } }), this regexp requires)immediately after the closing quote and extracts nothing. Both import-discipline checks then skip the forbidden dependency entirely, despite the helper's promise to cover dynamic imports; allow the optional comma/options argument and add a fixture for this valid NodeNext form.Useful? React with 👍 / 👎.