fix: Whisper liest /work/in nur, Restrisiko des Abschreib-Pods dokumentiert

Der Decoder-Container mountet /work/in schreibgeschützt und nur
/work/out beschreibbar (Policy-Test); ingest ignoriert Ergebnisse zu
Aufnahmen außerhalb des Manifests (Test). ARCHITECTURE Annahme 106
beschreibt das getragene Restrisiko (DNS im Pod-Netz, Sicht auf den
eigenen Lauf) und stärkere Optionen für später.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 11:16:59 +02:00
1 parent c53810b8a6
commit 484d799a36
4 files changed
+31 -4

No files matched your search

+1
View File
@@ -228,6 +228,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
103. **Die Historienzeilen haben keine Folgeknöpfe**: Eine Zeile der Beute-Historie zeigt die Änderung und den Stand an diesem Tag, bietet aber keine Vorschläge wie „Futter nachlegen“ an, weil ein Eintrag den Zustand davor nicht trägt; die Vorschläge erscheinen nur als Hinweis (Toast) direkt nach dem Speichern. Kosten bei falscher Annahme: Ein weggewischter Hinweis ist aus der Historie nicht wiederzufinden; Abhilfe wäre, `stateBefore` im Protokoll zu speichern (Migration). Abschnitt 13.1.
104. **Der Editor hält den Stand fest, auf dem der Entwurf begann**: Beim ersten Schritt merkt sich `useHiveDraft` den geladenen Aufbau (`snapshot`) und zeigt die Vorschau darauf; ändert sich der Aufbau auf dem Server inzwischen, erscheint ein Hinweis, und beim Speichern wendet der Server die Operationen auf den aktuellen Stand an. Passen sie nicht mehr, öffnet sich das Konflikt-Sheet, es wird nichts gespeichert. Kosten bei falscher Annahme: Die Vorschau im Editor weicht nach einer gleichzeitigen Änderung vom gespeicherten Ergebnis ab; der Konflikt-Weg fängt Unverträgliches ab. Abschnitt 13.1.
105. **Der Zoom der 3D-Szene (0,6 bis 2,5) kommt mit dem Ziehen und Ablegen**: Die Szene hat Drehen und Fokus auf ein Teil, aber noch keinen frei wählbaren Zoom; der Bereich ist der Aufgabe für Ziehen und Ablegen zugeordnet (Task 18). Kosten bei falscher Annahme: Auf kleinen Bildschirmen fehlt zunächst ein Zoom; der Browser-Zoom und die Listenansicht (Gliederung) bleiben als Weg. Abschnitt 13.1.
106. **Restrisiko im Abschreib-Pod wird getragen**: Der Container `whisper` (CronJob `voice-transcribe`, Komponente `deploy/k8s/components/voice`) dekodiert nicht vertrauenswürdige Audiodateien mit libav/PyAV und CTranslate2. Er teilt das Pod-Netz; die NetworkPolicy `apicore-jobs` erlaubt nur Postgres (5432) und DNS. Ein kompromittierter Decoder könnte über DNS-Anfragen langsam Daten nach außen tragen und Postgres erreichen, hat aber keine Zugangsdaten (kein `envFrom`, kein `secretKeyRef`, kein Service-Account-Token, eigenes `/tmp`, damit er `ingest` mit den Secrets nichts unterschieben kann). Er sieht Ton und Abschriften seines Laufs (höchstens 5 Aufnahmen, 600 s) und könnte deren Ergebnisse verfälschen; `/work/in` ist für ihn nur lesbar, und `ingest` übernimmt nur Ergebnisse zu Aufnahmen des eigenen Manifests mit passendem `claimToken`, prüft jedes Ergebnis streng, und nichts geht ohne Klick in ein Formular. Akzeptiert nach Spec 9.2: ein Betrieb mit eigenen Nutzern, keine Geheimnisse im Decoder, kein Internet, Schaden auf einen Lauf begrenzt. Das CronJob-Objekt trägt bewusst `app: apicore-voice-job` (nur das Pod-Template `app: apicore-job`), damit der gemeinsame Job-Patch keinen weiteren Container einsetzt; `av` ist wegen faster-whisper 1.2.1 auf 18.1.0 festgelegt. Kosten bei falscher Annahme: Abschriften eines Laufs könnten bei einer Lücke im Decoder abfließen oder vertauscht werden. Stärkere Optionen später: eigener Pod für den Decoder mit deny-all-NetworkPolicy (auch ohne DNS) und Übergabe-Volume, oder ein Subprozess je Aufnahme mit eigener Sandbox.
## 7. Kubernetes
@@ -175,6 +175,18 @@ describe('voice work dir', () => {
expect([...results.values()]).toEqual([null, null, null]);
});
it('ignores result files for captures that are not in the manifest', async () => {
const manifest = await writeInputs(dir, [
{ claim: CLAIM('ck1'), audio: Buffer.from('a') },
]);
await writeOut('ck1', result('ck1'));
await writeOut('planted', result('planted'));
const results = await readResults(dir, manifest);
expect([...results.keys()]).toEqual(['ck1']);
});
it('clears input and output', async () => {
await writeInputs(dir, [{ claim: CLAIM('ck1'), audio: Buffer.from('a') }]);
await writeOut('ck1', result('ck1'));
@@ -119,9 +119,16 @@ spec:
seccompProfile:
type: RuntimeDefault
volumeMounts:
# Der ganze Mount, Schreibrecht fuer /work/out.
# Eingabe nur lesbar (Manifest und Ton kann der Decoder nicht
# veraendern), Ausgabe schreibbar. fetch legt in/ und out/ an
# (clearWorkDir, Modus 0700, uid 1000), bevor dieser Container startet.
- name: work
mountPath: /work
mountPath: /work/in
subPath: in
readOnly: true
- name: work
mountPath: /work/out
subPath: out
- name: whisper-tmp
mountPath: /tmp
resources:
+9 -2
View File
@@ -581,7 +581,7 @@ test('whisper container has no env from secrets, no secretKeyRef and no service
}
assert.deepEqual(
(whisper.volumeMounts ?? []).map((m) => m.mountPath).sort(),
['/tmp', '/work'],
['/tmp', '/work/in', '/work/out'],
'whisper mounts more than the work volume and /tmp',
);
assert.equal(whisper.securityContext.runAsNonRoot, true);
@@ -629,11 +629,18 @@ test('work volume is a memory emptyDir of at most 32Mi, mounted in all three con
assert.ok(work?.emptyDir, 'work volume missing or not an emptyDir');
assert.equal(work.emptyDir.medium, 'Memory', 'work volume is not memory-backed');
assert.equal(work.emptyDir.sizeLimit, '32Mi');
for (const name of ['fetch', 'whisper', 'ingest']) {
for (const name of ['fetch', 'ingest']) {
const mount = (voiceContainer(pod, name).volumeMounts ?? []).find((m) => m.name === 'work');
assert.equal(mount?.mountPath, '/work', `${name} does not mount work on /work`);
assert.ok(!mount.readOnly, `${name} mounts work read-only`);
}
// whisper: /work/in read-only, /work/out writable, nothing else of the work volume.
const whisperWork = (voiceContainer(pod, 'whisper').volumeMounts ?? []).filter((m) => m.name === 'work');
assert.deepEqual(
whisperWork.map((m) => [m.mountPath, m.subPath, m.readOnly ?? false]).sort(),
[['/work/in', 'in', true], ['/work/out', 'out', false]].sort(),
'whisper must see /work/in read-only and /work/out writable only',
);
// fetch, whisper and ingest share the files (0700/0600): one uid, one fsGroup.
assert.equal(pod.spec.securityContext.runAsUser, 1000);
assert.equal(pod.spec.securityContext.fsGroup, 1000);