driftseal 1.3.0 → 1.3.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/README.md +17 -6
- package/README.zh-CN.md +14 -7
- package/bin/driftseal.js +256 -56
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -210,17 +210,28 @@ driftseal verify
|
|
|
210
210
|
`driftseal verify` passes the exact stored string to the operating-system shell.
|
|
211
211
|
The command can therefore read or modify files, access the network, or run any
|
|
212
212
|
other program available to the current user. Treat it as executable code, not as
|
|
213
|
-
passive log data.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
passive log data. DriftSeal records local provenance when an intent is opened:
|
|
214
|
+
the default Git workflow parks the intent in Git metadata, while non-Git and
|
|
215
|
+
custom `DRIFTSEAL_HOME` workflows keep a small local marker outside the intent
|
|
216
|
+
log. Those locally created intents run normally. If an open intent arrives only
|
|
217
|
+
through an intent log, without matching local provenance, DriftSeal cannot confirm
|
|
218
|
+
who chose its command. It prints the command to stderr and refuses to execute it
|
|
219
|
+
until you inspect it and explicitly run
|
|
220
|
+
`driftseal verify --allow-tracked-command`. The programmatic API and MCP tool
|
|
221
|
+
expose the equivalent `allowTrackedCommand` opt-in. Local provenance state is
|
|
222
|
+
removed when the intent closes. Non-Git markers are bound to the local log
|
|
223
|
+
file's identity, so copying a marker with the log does not transfer trust. If
|
|
224
|
+
local provenance is lost or no longer matches, verification fails safe and
|
|
225
|
+
requires the same explicit opt-in.
|
|
218
226
|
|
|
219
227
|
The verification event records the command's exit status, duration, output
|
|
220
228
|
digest and byte counts, Git HEAD, and a fingerprint of every tracked or
|
|
221
229
|
untracked non-ignored workspace file except the intent event log. A successful
|
|
222
230
|
result becomes stale if those workspace contents change. DriftSeal therefore
|
|
223
231
|
rejects `completed` until the command passes again on the current workspace.
|
|
232
|
+
Command output is spooled to temporary files instead of a fixed in-memory
|
|
233
|
+
buffer, then replayed after the command exits and removed. Output size therefore
|
|
234
|
+
has no DriftSeal-defined limit, though it remains bounded by available disk space.
|
|
224
235
|
Ignored files are deliberately outside this fingerprint. Outside a Git
|
|
225
236
|
worktree the fingerprint is unavailable, so the gate proves only the command's
|
|
226
237
|
recorded exit status and cannot detect later content changes.
|
|
@@ -255,7 +266,7 @@ also need no intent. Any other non-Git content change starts a new work round.
|
|
|
255
266
|
| Command | Purpose |
|
|
256
267
|
| --- | --- |
|
|
257
268
|
| `driftseal begin "<intent>" [--accept "<outcome>"] [-v "<command>"] [--decision id] [--force]` | Open a work-round intent. Repeat `--accept` for observable completion criteria; acceptance requires a verification command. |
|
|
258
|
-
| `driftseal verify [--allow-tracked-command]` | Execute the acceptance-bound intent's predeclared command and bind machine evidence to the current Git-visible workspace contents. Commands
|
|
269
|
+
| `driftseal verify [--allow-tracked-command]` | Execute the acceptance-bound intent's predeclared command and bind machine evidence to the current Git-visible workspace contents. Commands without matching local provenance require the explicit opt-in. |
|
|
259
270
|
| `driftseal end [id] [-s status] [-n note] [-r verify-result]` | Close an intent honestly. |
|
|
260
271
|
| `driftseal status` | Show the intent currently in progress. |
|
|
261
272
|
| `driftseal log [-n N] [--all]` | Review intent history (`--all` includes reclaimed records). |
|
package/README.zh-CN.md
CHANGED
|
@@ -199,16 +199,23 @@ driftseal verify
|
|
|
199
199
|
|
|
200
200
|
`driftseal verify` 会把日志中保存的完整字符串交给操作系统 shell。这个命令可以
|
|
201
201
|
读写文件、访问网络,也可以运行当前用户有权执行的任何程序;因此它是可执行代码,
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
202
|
+
不是被动的日志数据。创建 intent 时,DriftSeal 会记录本地 provenance:默认 Git
|
|
203
|
+
流程把 open intent park 在 Git metadata 中;非 Git 环境和自定义
|
|
204
|
+
`DRIFTSEAL_HOME` 则在 intent log 之外保存一个很小的本地标记。这些本地创建的
|
|
205
|
+
intent 可以直接验证。如果 open intent 只有 log 记录、没有匹配的本地 provenance,
|
|
206
|
+
DriftSeal 就无法确认是谁选择了其中的命令。此时它会先把命令输出到 stderr 并拒绝
|
|
207
|
+
执行;只有检查并信任该命令后,才能显式运行
|
|
208
|
+
`driftseal verify --allow-tracked-command`。Programmatic API 和 MCP tool 中对应的显式
|
|
209
|
+
开关是 `allowTrackedCommand`。intent 关闭时,本地 provenance 会被清理;如果它提前
|
|
210
|
+
丢失,DriftSeal 会按安全方向处理,仍要求显式 opt in。非 Git marker 还会绑定本地
|
|
211
|
+
log 文件的 identity,因此把 marker 和 log 一起复制到别处也不会转移信任。
|
|
207
212
|
|
|
208
213
|
验证事件会记录 exit status、耗时、输出摘要及字节数、Git HEAD,以及当前所有
|
|
209
214
|
tracked 和未被 ignore 的 untracked 文件的内容指纹(intent event log 除外)。
|
|
210
215
|
验证后只要这些内容发生变化,成功证据就会过期,必须重新运行;否则 DriftSeal
|
|
211
|
-
会拒绝把 intent 关闭为 `completed
|
|
216
|
+
会拒绝把 intent 关闭为 `completed`。命令输出会先写入临时 spool 文件,而不是
|
|
217
|
+
受固定大小的内存 buffer 限制;命令退出后再回放并删除。因此 DriftSeal 不再限制
|
|
218
|
+
输出大小,但实际容量仍受可用磁盘空间约束。被 ignore 的文件不在指纹范围内。
|
|
212
219
|
如果当前目录不是 Git worktree,指纹不可用;此时 gate 只能证明记录到的 exit
|
|
213
220
|
status,无法发现之后发生的内容变化。
|
|
214
221
|
|
|
@@ -240,7 +247,7 @@ intent;会被提交且无法重建的内容改动(比如编辑 `.gitignore`
|
|
|
240
247
|
| Command | 用途 |
|
|
241
248
|
| --- | --- |
|
|
242
249
|
| `driftseal begin "<intent>" [--accept "<outcome>"] [-v "<command>"] [--decision id] [--force]` | 开启一轮工作。可重复使用 `--accept` 声明可观察的完成条件;一旦声明,就必须同时提供验证命令。 |
|
|
243
|
-
| `driftseal verify [--allow-tracked-command]` | 执行 acceptance-bound intent 预先声明的命令,并把机器证据绑定到当前 Git
|
|
250
|
+
| `driftseal verify [--allow-tracked-command]` | 执行 acceptance-bound intent 预先声明的命令,并把机器证据绑定到当前 Git 可见的工作区内容;没有匹配本地 provenance 的命令必须显式 opt in。 |
|
|
244
251
|
| `driftseal end [id] [-s status] [-n note] [-r verify-result]` | 诚实地关闭 intent。 |
|
|
245
252
|
| `driftseal status` | 查看当前进行中的 intent。 |
|
|
246
253
|
| `driftseal log [-n N] [--all]` | 查看 intent 历史(`--all` 包含已回收的记录)。 |
|
package/bin/driftseal.js
CHANGED
|
@@ -27,6 +27,7 @@ const path = require('path');
|
|
|
27
27
|
const crypto = require('crypto');
|
|
28
28
|
const os = require('os');
|
|
29
29
|
const { isDeepStrictEqual } = require('util');
|
|
30
|
+
const { StringDecoder } = require('string_decoder');
|
|
30
31
|
const { execFileSync, spawnSync } = require('child_process');
|
|
31
32
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
32
33
|
|
|
@@ -48,6 +49,10 @@ const LOCK_INIT_STALE_MS = 5 * 1000;
|
|
|
48
49
|
const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
|
|
49
50
|
const READ_ONLY_LOCK_WAIT_MS = Number(process.env._DRIFTSEAL_TEST_READ_ONLY_LOCK_WAIT_MS) || 1500;
|
|
50
51
|
const MAX_DECISION_SLUG_LENGTH = 180;
|
|
52
|
+
const VERIFICATION_OUTPUT_CHUNK_BYTES = 64 * 1024;
|
|
53
|
+
const CAPTURE_OUTPUT_EDGE_CHARACTERS = 32 * 1024;
|
|
54
|
+
const CAPTURE_OUTPUT_OMISSION = '\n... [driftseal captured output truncated] ...\n';
|
|
55
|
+
const LOCAL_INTENT_PROVENANCE_FILE = '.driftseal-local-intent.json';
|
|
51
56
|
|
|
52
57
|
class DriftSealError extends Error {
|
|
53
58
|
constructor(message) {
|
|
@@ -98,39 +103,70 @@ function usageFor(key) {
|
|
|
98
103
|
|
|
99
104
|
let activeOutput = null;
|
|
100
105
|
|
|
106
|
+
function createBoundedOutputCapture() {
|
|
107
|
+
return { head: '', tail: '', length: 0, headComplete: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function splitsSurrogatePair(text, index) {
|
|
111
|
+
if (index <= 0 || index >= text.length) return false;
|
|
112
|
+
const before = text.charCodeAt(index - 1);
|
|
113
|
+
const after = text.charCodeAt(index);
|
|
114
|
+
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function appendBoundedOutput(capture, value) {
|
|
118
|
+
let text = String(value);
|
|
119
|
+
capture.length += text.length;
|
|
120
|
+
|
|
121
|
+
if (!capture.headComplete) {
|
|
122
|
+
let take = Math.min(CAPTURE_OUTPUT_EDGE_CHARACTERS - capture.head.length, text.length);
|
|
123
|
+
if (splitsSurrogatePair(text, take)) take -= 1;
|
|
124
|
+
capture.head += text.slice(0, take);
|
|
125
|
+
text = text.slice(take);
|
|
126
|
+
if (text.length > 0) capture.headComplete = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (text.length === 0) return;
|
|
130
|
+
const combined = capture.tail + text;
|
|
131
|
+
let start = Math.max(0, combined.length - CAPTURE_OUTPUT_EDGE_CHARACTERS);
|
|
132
|
+
if (splitsSurrogatePair(combined, start)) start += 1;
|
|
133
|
+
capture.tail = combined.slice(start);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderBoundedOutput(capture) {
|
|
137
|
+
if (capture.length === capture.head.length + capture.tail.length) {
|
|
138
|
+
return capture.head + capture.tail;
|
|
139
|
+
}
|
|
140
|
+
return capture.head + CAPTURE_OUTPUT_OMISSION + capture.tail;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function captureOutput(stream, value) {
|
|
144
|
+
if (!activeOutput) return false;
|
|
145
|
+
appendBoundedOutput(activeOutput[stream], value);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
101
149
|
function printLine(value = '') {
|
|
102
150
|
const text = String(value);
|
|
103
|
-
if (
|
|
104
|
-
activeOutput.stdout += text + '\n';
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
151
|
+
if (captureOutput('stdout', text + '\n')) return;
|
|
107
152
|
console.log(text);
|
|
108
153
|
}
|
|
109
154
|
|
|
110
155
|
function printError(value = '') {
|
|
111
156
|
const text = String(value);
|
|
112
|
-
if (
|
|
113
|
-
activeOutput.stderr += text + '\n';
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
157
|
+
if (captureOutput('stderr', text + '\n')) return;
|
|
116
158
|
console.error(text);
|
|
117
159
|
}
|
|
118
160
|
|
|
119
161
|
function writeOutput(value) {
|
|
120
162
|
const text = String(value);
|
|
121
|
-
if (
|
|
122
|
-
activeOutput.stdout += text;
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
163
|
+
if (captureOutput('stdout', text)) return;
|
|
125
164
|
process.stdout.write(text);
|
|
126
165
|
}
|
|
127
166
|
|
|
128
167
|
function writeErrorOutput(value) {
|
|
129
168
|
const text = String(value);
|
|
130
|
-
if (
|
|
131
|
-
activeOutput.stderr += text;
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
169
|
+
if (captureOutput('stderr', text)) return;
|
|
134
170
|
process.stderr.write(text);
|
|
135
171
|
}
|
|
136
172
|
|
|
@@ -569,7 +605,12 @@ function parkedOpenIntent(park) {
|
|
|
569
605
|
|
|
570
606
|
function appendEvent(event) {
|
|
571
607
|
const park = inProgressFile();
|
|
572
|
-
if (!park)
|
|
608
|
+
if (!park) {
|
|
609
|
+
const stored = appendEventTo(logFile(), event);
|
|
610
|
+
if (event.type === 'begin') writeLocalIntentProvenance(stored);
|
|
611
|
+
if (event.type === 'end') clearLocalIntentProvenance(event.id);
|
|
612
|
+
return stored;
|
|
613
|
+
}
|
|
573
614
|
|
|
574
615
|
const open = parkedOpenIntent(park);
|
|
575
616
|
// A park with nothing open left in it belongs in the log; an interrupted end retries here.
|
|
@@ -591,9 +632,88 @@ function contentHash(content) {
|
|
|
591
632
|
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
592
633
|
}
|
|
593
634
|
|
|
594
|
-
function
|
|
635
|
+
function localIntentProvenanceFile() {
|
|
636
|
+
const root = gitWorktreeRoot();
|
|
637
|
+
const key = contentHash(path.resolve(logFile())).slice(0, 16);
|
|
638
|
+
if (!root) return path.join(logDir(), LOCAL_INTENT_PROVENANCE_FILE);
|
|
639
|
+
const gitPath = gitCapture(['rev-parse', '--git-path', `driftseal-local-intent-${key}.json`]);
|
|
640
|
+
return gitPath ? path.resolve(process.cwd(), gitPath) : null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function localIntentLogIdentity() {
|
|
644
|
+
try {
|
|
645
|
+
const stat = fs.statSync(logFile(), { bigint: true });
|
|
646
|
+
return contentHash(JSON.stringify([String(stat.dev), String(stat.ino), String(stat.birthtimeNs)]));
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function localIntentProvenanceFingerprint({ id, ts, verify }) {
|
|
653
|
+
return contentHash(JSON.stringify([id, ts, verify || null]));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function writeLocalIntentProvenance(event) {
|
|
657
|
+
const file = localIntentProvenanceFile();
|
|
658
|
+
if (!file) return;
|
|
659
|
+
ensureDirectoryDurable(path.dirname(file));
|
|
660
|
+
atomicWriteFile(
|
|
661
|
+
file,
|
|
662
|
+
JSON.stringify({
|
|
663
|
+
version: 1,
|
|
664
|
+
id: event.id,
|
|
665
|
+
fingerprint: localIntentProvenanceFingerprint(event),
|
|
666
|
+
logIdentity: localIntentLogIdentity(),
|
|
667
|
+
}) + '\n',
|
|
668
|
+
0o600
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function readLocalIntentProvenance() {
|
|
673
|
+
const file = localIntentProvenanceFile();
|
|
674
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
675
|
+
try {
|
|
676
|
+
const provenance = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
677
|
+
if (
|
|
678
|
+
provenance.version !== 1 ||
|
|
679
|
+
typeof provenance.id !== 'string' ||
|
|
680
|
+
!/^[a-f0-9]{64}$/.test(provenance.fingerprint) ||
|
|
681
|
+
!/^[a-f0-9]{64}$/.test(provenance.logIdentity)
|
|
682
|
+
) {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
return provenance;
|
|
686
|
+
} catch {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function hasMatchingLocalIntentProvenance(intent) {
|
|
692
|
+
const provenance = readLocalIntentProvenance();
|
|
693
|
+
return (
|
|
694
|
+
provenance !== null &&
|
|
695
|
+
provenance.id === intent.id &&
|
|
696
|
+
provenance.logIdentity === localIntentLogIdentity() &&
|
|
697
|
+
provenance.fingerprint ===
|
|
698
|
+
localIntentProvenanceFingerprint({
|
|
699
|
+
id: intent.id,
|
|
700
|
+
ts: intent.tsBegin,
|
|
701
|
+
verify: intent.verify,
|
|
702
|
+
})
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function clearLocalIntentProvenance(id) {
|
|
707
|
+
const file = localIntentProvenanceFile();
|
|
708
|
+
const provenance = readLocalIntentProvenance();
|
|
709
|
+
if (!file || !provenance || provenance.id !== id) return;
|
|
710
|
+
fs.unlinkSync(file);
|
|
711
|
+
fsyncDirectory(path.dirname(file));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function atomicWriteFile(target, content, createMode = 0o644) {
|
|
595
715
|
const existed = fs.existsSync(target);
|
|
596
|
-
const mode = existed ? fs.statSync(target).mode & 0o777 :
|
|
716
|
+
const mode = existed ? fs.statSync(target).mode & 0o777 : createMode;
|
|
597
717
|
const temp = path.join(
|
|
598
718
|
path.dirname(target),
|
|
599
719
|
`.${path.basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
@@ -3802,6 +3922,101 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
|
|
|
3802
3922
|
});
|
|
3803
3923
|
}
|
|
3804
3924
|
|
|
3925
|
+
function appendVerificationSpawnError(file, error) {
|
|
3926
|
+
const stat = fs.statSync(file);
|
|
3927
|
+
let prefix = '';
|
|
3928
|
+
if (stat.size > 0) {
|
|
3929
|
+
const fd = fs.openSync(file, 'r');
|
|
3930
|
+
const lastByte = Buffer.alloc(1);
|
|
3931
|
+
try {
|
|
3932
|
+
fs.readSync(fd, lastByte, 0, 1, stat.size - 1);
|
|
3933
|
+
} finally {
|
|
3934
|
+
fs.closeSync(fd);
|
|
3935
|
+
}
|
|
3936
|
+
if (lastByte[0] !== 0x0a) prefix = '\n';
|
|
3937
|
+
}
|
|
3938
|
+
fs.appendFileSync(file, `${prefix}${error.message}\n`, 'utf8');
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
function digestAndReplayVerificationOutput(file, writer, hash) {
|
|
3942
|
+
const fd = fs.openSync(file, 'r');
|
|
3943
|
+
const decoder = new StringDecoder('utf8');
|
|
3944
|
+
const buffer = Buffer.allocUnsafe(VERIFICATION_OUTPUT_CHUNK_BYTES);
|
|
3945
|
+
let bytes = 0;
|
|
3946
|
+
let lastCharacter = null;
|
|
3947
|
+
|
|
3948
|
+
const display = (text) => {
|
|
3949
|
+
if (!text) return;
|
|
3950
|
+
writer(text);
|
|
3951
|
+
lastCharacter = text.at(-1);
|
|
3952
|
+
};
|
|
3953
|
+
|
|
3954
|
+
try {
|
|
3955
|
+
while (true) {
|
|
3956
|
+
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
3957
|
+
if (count === 0) break;
|
|
3958
|
+
const chunk = buffer.subarray(0, count);
|
|
3959
|
+
hash.update(chunk);
|
|
3960
|
+
bytes += count;
|
|
3961
|
+
display(decoder.write(chunk));
|
|
3962
|
+
}
|
|
3963
|
+
display(decoder.end());
|
|
3964
|
+
} finally {
|
|
3965
|
+
fs.closeSync(fd);
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
return { bytes, endsWithNewline: lastCharacter === '\n' };
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
function executeVerificationCommand(command) {
|
|
3972
|
+
const spool = fs.mkdtempSync(path.join(os.tmpdir(), 'driftseal-verify-'));
|
|
3973
|
+
const stdoutFile = path.join(spool, 'stdout');
|
|
3974
|
+
const stderrFile = path.join(spool, 'stderr');
|
|
3975
|
+
let stdoutFd;
|
|
3976
|
+
let stderrFd;
|
|
3977
|
+
|
|
3978
|
+
try {
|
|
3979
|
+
stdoutFd = fs.openSync(stdoutFile, 'wx', 0o600);
|
|
3980
|
+
stderrFd = fs.openSync(stderrFile, 'wx', 0o600);
|
|
3981
|
+
const started = process.hrtime.bigint();
|
|
3982
|
+
let result;
|
|
3983
|
+
try {
|
|
3984
|
+
result = spawnSync(command, {
|
|
3985
|
+
cwd: process.cwd(),
|
|
3986
|
+
env: process.env,
|
|
3987
|
+
shell: true,
|
|
3988
|
+
stdio: ['ignore', stdoutFd, stderrFd],
|
|
3989
|
+
});
|
|
3990
|
+
} finally {
|
|
3991
|
+
fs.closeSync(stdoutFd);
|
|
3992
|
+
stdoutFd = undefined;
|
|
3993
|
+
fs.closeSync(stderrFd);
|
|
3994
|
+
stderrFd = undefined;
|
|
3995
|
+
}
|
|
3996
|
+
const durationMs = Number((process.hrtime.bigint() - started) / 1000000n);
|
|
3997
|
+
if (result.error) appendVerificationSpawnError(stderrFile, result.error);
|
|
3998
|
+
|
|
3999
|
+
const hash = crypto.createHash('sha256');
|
|
4000
|
+
const stdout = digestAndReplayVerificationOutput(stdoutFile, writeOutput, hash);
|
|
4001
|
+
if (stdout.bytes > 0 && !stdout.endsWithNewline) printLine();
|
|
4002
|
+
hash.update('\0');
|
|
4003
|
+
const stderr = digestAndReplayVerificationOutput(stderrFile, writeErrorOutput, hash);
|
|
4004
|
+
if (stderr.bytes > 0 && !stderr.endsWithNewline) printError();
|
|
4005
|
+
|
|
4006
|
+
return {
|
|
4007
|
+
result,
|
|
4008
|
+
durationMs,
|
|
4009
|
+
outputHash: hash.digest('hex'),
|
|
4010
|
+
stdoutBytes: stdout.bytes,
|
|
4011
|
+
stderrBytes: stderr.bytes,
|
|
4012
|
+
};
|
|
4013
|
+
} finally {
|
|
4014
|
+
if (stdoutFd !== undefined) fs.closeSync(stdoutFd);
|
|
4015
|
+
if (stderrFd !== undefined) fs.closeSync(stderrFd);
|
|
4016
|
+
fs.rmSync(spool, { recursive: true, force: true });
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
|
|
3805
4020
|
function runMachineVerification({ allowTrackedCommand = false } = {}) {
|
|
3806
4021
|
const snapshot = withMutationLocks([logDir()], () => {
|
|
3807
4022
|
const intent = openIntent(fold(readEvents({ repairTail: true })));
|
|
@@ -3811,56 +4026,31 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
|
|
|
3811
4026
|
}
|
|
3812
4027
|
if (!intent.verify) fail(`intent ${intent.id} has no verification command`);
|
|
3813
4028
|
const park = inProgressFile();
|
|
3814
|
-
const parked = park
|
|
4029
|
+
const parked = park ? parkedOpenIntent(park) : null;
|
|
4030
|
+
const locallyProvenanced = hasMatchingLocalIntentProvenance(intent);
|
|
3815
4031
|
return {
|
|
3816
4032
|
id: intent.id,
|
|
3817
4033
|
command: intent.verify,
|
|
3818
|
-
|
|
4034
|
+
requiresExplicitTrust:
|
|
4035
|
+
(!parked || parked.id !== intent.id) && !locallyProvenanced,
|
|
3819
4036
|
};
|
|
3820
4037
|
});
|
|
3821
4038
|
|
|
3822
4039
|
const displayedCommand = JSON.stringify(snapshot.command);
|
|
3823
4040
|
printError(`verification command: ${displayedCommand}`);
|
|
3824
|
-
if (snapshot.
|
|
4041
|
+
if (snapshot.requiresExplicitTrust && !allowTrackedCommand) {
|
|
3825
4042
|
fail(
|
|
3826
|
-
`refusing to execute a verification command
|
|
4043
|
+
`refusing to execute a verification command that DriftSeal cannot confirm was created locally: ${displayedCommand}\n` +
|
|
4044
|
+
'no matching local intent provenance was found; ' +
|
|
3827
4045
|
'inspect the command, then re-run with --allow-tracked-command only if you trust it'
|
|
3828
4046
|
);
|
|
3829
4047
|
}
|
|
3830
4048
|
|
|
3831
|
-
const
|
|
3832
|
-
const result
|
|
3833
|
-
cwd: process.cwd(),
|
|
3834
|
-
env: process.env,
|
|
3835
|
-
shell: true,
|
|
3836
|
-
encoding: 'utf8',
|
|
3837
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3838
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
3839
|
-
});
|
|
3840
|
-
const durationMs = Number((process.hrtime.bigint() - started) / 1000000n);
|
|
3841
|
-
const stdout = result.stdout || '';
|
|
3842
|
-
let stderr = result.stderr || '';
|
|
3843
|
-
if (result.error) {
|
|
3844
|
-
stderr += `${stderr && !stderr.endsWith('\n') ? '\n' : ''}${result.error.message}\n`;
|
|
3845
|
-
}
|
|
3846
|
-
if (stdout) {
|
|
3847
|
-
writeOutput(stdout);
|
|
3848
|
-
if (!stdout.endsWith('\n')) printLine();
|
|
3849
|
-
}
|
|
3850
|
-
if (stderr) {
|
|
3851
|
-
writeErrorOutput(stderr);
|
|
3852
|
-
if (!stderr.endsWith('\n')) printError();
|
|
3853
|
-
}
|
|
3854
|
-
|
|
4049
|
+
const execution = executeVerificationCommand(snapshot.command);
|
|
4050
|
+
const { result, durationMs, outputHash, stdoutBytes, stderrBytes } = execution;
|
|
3855
4051
|
const exitCode = Number.isInteger(result.status) ? result.status : 1;
|
|
3856
4052
|
const signal = typeof result.signal === 'string' ? result.signal : null;
|
|
3857
4053
|
const passed = exitCode === 0 && signal === null;
|
|
3858
|
-
const outputHash = crypto
|
|
3859
|
-
.createHash('sha256')
|
|
3860
|
-
.update(stdout, 'utf8')
|
|
3861
|
-
.update('\0')
|
|
3862
|
-
.update(stderr, 'utf8')
|
|
3863
|
-
.digest('hex');
|
|
3864
4054
|
const verificationEvent = {
|
|
3865
4055
|
type: 'verify',
|
|
3866
4056
|
id: snapshot.id,
|
|
@@ -3872,8 +4062,8 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
|
|
|
3872
4062
|
signal,
|
|
3873
4063
|
durationMs,
|
|
3874
4064
|
outputHash,
|
|
3875
|
-
stdoutBytes
|
|
3876
|
-
stderrBytes
|
|
4065
|
+
stdoutBytes,
|
|
4066
|
+
stderrBytes,
|
|
3877
4067
|
workspace: workspaceFingerprint(),
|
|
3878
4068
|
head: gitCapture(['rev-parse', 'HEAD']),
|
|
3879
4069
|
};
|
|
@@ -4727,7 +4917,15 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
|
|
|
4727
4917
|
const previousIntentHome = process.env.DRIFTSEAL_HOME;
|
|
4728
4918
|
const previousDecisionHome = process.env.DRIFTSEAL_DECISION_HOME;
|
|
4729
4919
|
const output = { stdout: '', stderr: '', data: null, exitCode: 0, readOnly: false };
|
|
4920
|
+
const captures = capture
|
|
4921
|
+
? { stdout: createBoundedOutputCapture(), stderr: createBoundedOutputCapture() }
|
|
4922
|
+
: null;
|
|
4730
4923
|
const previousOutput = activeOutput;
|
|
4924
|
+
const finalizeCapturedOutput = () => {
|
|
4925
|
+
if (!captures) return;
|
|
4926
|
+
output.stdout = renderBoundedOutput(captures.stdout);
|
|
4927
|
+
output.stderr = renderBoundedOutput(captures.stderr);
|
|
4928
|
+
};
|
|
4731
4929
|
|
|
4732
4930
|
try {
|
|
4733
4931
|
process.chdir(fixedRoot);
|
|
@@ -4735,14 +4933,16 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
|
|
|
4735
4933
|
delete process.env.DRIFTSEAL_HOME;
|
|
4736
4934
|
delete process.env.DRIFTSEAL_DECISION_HOME;
|
|
4737
4935
|
}
|
|
4738
|
-
if (capture) activeOutput =
|
|
4936
|
+
if (capture) activeOutput = captures;
|
|
4739
4937
|
const result = dispatch(argv);
|
|
4740
4938
|
output.data = result.data;
|
|
4741
4939
|
output.exitCode = result.exitCode;
|
|
4742
4940
|
output.readOnly = result.readOnly === true;
|
|
4941
|
+
finalizeCapturedOutput();
|
|
4743
4942
|
return output;
|
|
4744
4943
|
} catch (err) {
|
|
4745
4944
|
if (capture) {
|
|
4945
|
+
finalizeCapturedOutput();
|
|
4746
4946
|
err.stdout = output.stdout;
|
|
4747
4947
|
err.stderr = output.stderr;
|
|
4748
4948
|
}
|