@guilz-dev/belay 0.9.0 → 0.9.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 +7 -1
- package/dist/adapters/claude/runtime-entry.js +2 -0
- package/dist/adapters/codex/runtime-entry.js +2 -0
- package/dist/adapters/cursor/hooks.d.ts +6 -1
- package/dist/adapters/cursor/hooks.js +23 -0
- package/dist/adapters/cursor/runtime-entry.js +5 -0
- package/dist/adapters/shared/gate-runtime.d.ts +1 -0
- package/dist/adapters/shared/gate-runtime.js +49 -52
- package/dist/bundle/claude-runtime.mjs +4295 -3624
- package/dist/bundle/codex-runtime.mjs +4136 -3465
- package/dist/bundle/cursor-runtime.mjs +4137 -3463
- package/dist/commands/doctor.js +21 -3
- package/dist/core/audit-analysis.js +7 -4
- package/dist/core/audit-io.d.ts +1 -0
- package/dist/core/audit-io.js +8 -6
- package/dist/core/audit-legacy-archive.d.ts +7 -0
- package/dist/core/audit-legacy-archive.js +56 -0
- package/dist/core/audit-metrics.d.ts +4 -0
- package/dist/core/audit-metrics.js +2 -2
- package/dist/core/audit-query.d.ts +2 -0
- package/dist/core/audit-query.js +33 -9
- package/dist/core/audit-recovery-metrics.js +2 -2
- package/dist/core/audit-serialize.d.ts +9 -0
- package/dist/core/audit-serialize.js +179 -0
- package/dist/core/audit-types.d.ts +1 -0
- package/dist/core/capability/policy-engine.js +32 -13
- package/dist/core/decision-config-fingerprint.d.ts +3 -0
- package/dist/core/decision-config-fingerprint.js +6 -0
- package/dist/core/effect-ir/shell-lower.js +208 -2
- package/dist/core/verdict/egress-classify.js +1 -0
- package/dist/core/verdict/launcher-resolve.js +51 -10
- package/dist/core/verdict/makefile-expand.d.ts +4 -0
- package/dist/core/verdict/makefile-expand.js +151 -0
- package/dist/core/verdict/parser.d.ts +1 -0
- package/dist/core/verdict/parser.js +24 -12
- package/dist/corpus/benign-probe-cores.d.ts +1 -1
- package/dist/corpus/benign-probe-cores.js +2 -0
- package/dist/defaults.js +5 -4
- package/dist/egress-daemon.js +20 -4
- package/dist/installer.js +3 -0
- package/dist/runtime-provenance.d.ts +10 -0
- package/dist/runtime-provenance.js +63 -0
- package/dist/templates.js +12 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -3,12 +3,14 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { getClaudeManagedHookEntries } from '../adapters/claude/hooks.js';
|
|
5
5
|
import { getCodexManagedHookEntries } from '../adapters/codex/hooks.js';
|
|
6
|
+
import { hasDuplicateCursorShellGates } from '../adapters/cursor/hooks.js';
|
|
6
7
|
import { getAdapterLayout } from '../adapters/layouts/index.js';
|
|
7
8
|
import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
|
|
8
9
|
import { resolveScopedPaths } from '../adapters/layouts/scope.js';
|
|
9
10
|
import { cleanupOrphanApprovalState } from '../cleanup-orphans.js';
|
|
10
11
|
import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredConfig, pendingApprovalsPath, repoLocalStateDirFor, writeConfigFile, } from '../config-io.js';
|
|
11
12
|
import { approvalSigningKeyPath } from '../core/approval-token.js';
|
|
13
|
+
import { auditRecordHasLegacyCorrelationPlaceholders } from '../core/audit-legacy-archive.js';
|
|
12
14
|
import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summary.js';
|
|
13
15
|
import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
|
|
14
16
|
import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
|
|
@@ -24,7 +26,7 @@ import { probeFileCloneStrategy } from '../core/transactional/file-clone.js';
|
|
|
24
26
|
import { isGitWorktreeAvailable } from '../core/transactional/git-worktree.js';
|
|
25
27
|
import { getManagedHookEntries } from '../defaults.js';
|
|
26
28
|
import { resolveNodeBinary } from '../node-resolution.js';
|
|
27
|
-
import { readInstalledRuntimeProvenance } from '../runtime-provenance.js';
|
|
29
|
+
import { matchesAuditCohort, readInstalledRuntimeProvenance } from '../runtime-provenance.js';
|
|
28
30
|
import { egressStatus } from '../services/egress-service.js';
|
|
29
31
|
import { sandboxStatus } from '../services/sandbox-service.js';
|
|
30
32
|
import { PACKAGE_VERSION } from '../version.js';
|
|
@@ -98,6 +100,14 @@ export async function doctorProject(options = {}) {
|
|
|
98
100
|
? `Install scope: global (hooks/runtime at ${scopedPaths.hooksDir})`
|
|
99
101
|
: 'Install scope: project');
|
|
100
102
|
notes.push(`Config mode: ${loadedConfig.mode}`);
|
|
103
|
+
const dogfoodActive = loadedConfig.mode === 'audit' && loadedConfig.policy.unknownLocalEffect === 'deny';
|
|
104
|
+
const allGatesDisabled = !loadedConfig.gates.shell &&
|
|
105
|
+
!loadedConfig.gates.toolShell &&
|
|
106
|
+
!loadedConfig.gates.fileMutation &&
|
|
107
|
+
!loadedConfig.gates.subagent;
|
|
108
|
+
if (dogfoodActive && allGatesDisabled) {
|
|
109
|
+
issues.push('Dogfood audit mode is active but all gates are disabled; gate events will not be recorded. Enable gates.shell, gates.toolShell, gates.fileMutation, and/or gates.subagent.');
|
|
110
|
+
}
|
|
101
111
|
notes.push('Verdict engine (Tier0 + Tier1; location × opacity × effect × confidence). Audit records include schemaVersion 2 axes when available.');
|
|
102
112
|
const repoLocalDir = repoLocalStateDirFor(repoRoot, loadedConfig);
|
|
103
113
|
if (loadedConfig.controlPlane.enabled) {
|
|
@@ -181,6 +191,9 @@ export async function doctorProject(options = {}) {
|
|
|
181
191
|
issues.push(`Missing managed hook for ${event}: ${definition.command}${matcherSuffix}`);
|
|
182
192
|
}
|
|
183
193
|
}
|
|
194
|
+
if (hasDuplicateCursorShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
|
|
195
|
+
warnings.push('Duplicate Cursor Shell preToolUse gates detected. Run belay upgrade to dedupe managed hooks.');
|
|
196
|
+
}
|
|
184
197
|
}
|
|
185
198
|
else if (adapterName === 'codex') {
|
|
186
199
|
// Codex hooks live in TOML (.codex/config.toml). Verify belay's managed command strings
|
|
@@ -280,9 +293,14 @@ export async function doctorProject(options = {}) {
|
|
|
280
293
|
const metrics = await metricsProject({ targetDir: repoRoot });
|
|
281
294
|
const cohortIdentity = metrics.currentCohort.identity;
|
|
282
295
|
const cohortAuditRecords = cohortIdentity
|
|
283
|
-
? auditRecords.filter((record) => record
|
|
284
|
-
record.configFingerprint === cohortIdentity.configFingerprint)
|
|
296
|
+
? auditRecords.filter((record) => matchesAuditCohort(record, cohortIdentity))
|
|
285
297
|
: [];
|
|
298
|
+
const gateDecisionRecords = cohortAuditRecords.filter((record) => record.kind === 'shell' || record.kind === 'tool' || record.kind === 'subagent');
|
|
299
|
+
if (gateDecisionRecords
|
|
300
|
+
.slice(0, 200)
|
|
301
|
+
.some((record) => auditRecordHasLegacyCorrelationPlaceholders(record))) {
|
|
302
|
+
warnings.push('Gate audit records contain scrub placeholders in correlation fields (<timestamp>, <high-entropy>, <approval-id>). Historical metrics are unreliable until a schema v3 cohort is collected.');
|
|
303
|
+
}
|
|
286
304
|
const auditVisibility = summarizeAuditVisibility(cohortAuditRecords);
|
|
287
305
|
const drift = detectFenceDrift(auditVisibility, {
|
|
288
306
|
threshold: loadedConfig.policy.fenceWarnThreshold,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { inferWouldBlock, isGateRecord, parseTimestamp } from './audit-query.js';
|
|
1
|
+
import { auditFingerprint, inferWouldBlock, isGateRecord, parseTimestamp } from './audit-query.js';
|
|
2
2
|
const WRAPPER_TERMS = ['bash -c', 'sh -c', 'eval ', 'source ', 'node -e', '| bash', '| sh'];
|
|
3
3
|
function normalizeSummary(summary) {
|
|
4
4
|
return summary.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
@@ -26,7 +26,7 @@ export function detectBypassAttempts(records, windowMs = 5 * 60_000) {
|
|
|
26
26
|
const recentDenies = [];
|
|
27
27
|
for (const record of records) {
|
|
28
28
|
const timestampMs = parseTimestamp(record.timestamp) ?? 0;
|
|
29
|
-
if (isGateRecord(record) && inferWouldBlock(record) && record
|
|
29
|
+
if (isGateRecord(record) && inferWouldBlock(record) && auditFingerprint(record)) {
|
|
30
30
|
recentDenies.push({ timestampMs, record });
|
|
31
31
|
continue;
|
|
32
32
|
}
|
|
@@ -233,10 +233,13 @@ export function computeAvailabilityAskCounts(records) {
|
|
|
233
233
|
export function computeRepeatedFingerprintAsks(records, minCount = 2, limit = 10) {
|
|
234
234
|
const grouped = new Map();
|
|
235
235
|
for (const record of records) {
|
|
236
|
-
if (!isGateRecord(record) || !inferWouldBlock(record)
|
|
236
|
+
if (!isGateRecord(record) || !inferWouldBlock(record)) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const fingerprint = auditFingerprint(record);
|
|
240
|
+
if (!fingerprint) {
|
|
237
241
|
continue;
|
|
238
242
|
}
|
|
239
|
-
const fingerprint = record.fingerprint;
|
|
240
243
|
const existing = grouped.get(fingerprint);
|
|
241
244
|
if (existing) {
|
|
242
245
|
existing.askCount += 1;
|
package/dist/core/audit-io.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { BelayConfigV4 } from './config.js';
|
|
2
|
+
export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, } from './audit-serialize.js';
|
|
2
3
|
export declare function appendCliAuditEvent(repoRoot: string, config: BelayConfigV4, event: Record<string, unknown>): Promise<void>;
|
package/dist/core/audit-io.js
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
import { resolveActiveAuditCohort } from '../runtime-provenance.js';
|
|
3
|
+
import { appendAuditRecord } from './audit-serialize.js';
|
|
4
|
+
import { scrubOptionsFromConfig } from './config.js';
|
|
5
|
+
export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, } from './audit-serialize.js';
|
|
4
6
|
export async function appendCliAuditEvent(repoRoot, config, event) {
|
|
5
7
|
const auditPath = path.isAbsolute(config.audit.logPath)
|
|
6
8
|
? config.audit.logPath
|
|
7
9
|
: path.join(repoRoot, config.audit.logPath);
|
|
8
|
-
await mkdir(path.dirname(auditPath), { recursive: true });
|
|
9
10
|
const cohort = await resolveActiveAuditCohort(repoRoot, config);
|
|
10
|
-
|
|
11
|
-
ts: new Date().toISOString(),
|
|
11
|
+
await appendAuditRecord(auditPath, {
|
|
12
12
|
source: 'belay-cli',
|
|
13
13
|
...(cohort
|
|
14
14
|
? {
|
|
15
15
|
runtimeBuildStamp: cohort.runtimeBuildStamp,
|
|
16
|
+
runtimeArtifactHash: cohort.runtimeArtifactHash,
|
|
17
|
+
decisionConfigFingerprint: cohort.decisionConfigFingerprint,
|
|
18
|
+
boundaryProfile: cohort.boundaryProfile,
|
|
16
19
|
configFingerprint: cohort.configFingerprint,
|
|
17
20
|
}
|
|
18
21
|
: {}),
|
|
19
22
|
...event,
|
|
20
|
-
});
|
|
21
|
-
await appendFile(auditPath, `${line}\n`, 'utf8');
|
|
23
|
+
}, scrubOptionsFromConfig(config));
|
|
22
24
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BelayConfigV3 } from './config.js';
|
|
2
|
+
export declare function auditLogHasLegacyScrubPlaceholders(sample: string): boolean;
|
|
3
|
+
export declare function auditRecordHasLegacyCorrelationPlaceholders(record: Record<string, unknown>): boolean;
|
|
4
|
+
export declare function archiveLegacyAuditLogIfNeeded(repoRoot: string, config: BelayConfigV3): Promise<{
|
|
5
|
+
archived: boolean;
|
|
6
|
+
archivedPath?: string;
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { open, rename } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const LEGACY_PLACEHOLDER_PATTERNS = [
|
|
5
|
+
/"(?:timestamp|ts)"\s*:\s*"<timestamp>"/,
|
|
6
|
+
/"(?:fingerprint|commandFingerprint|effectIRHash|payloadHash|configFingerprint|runtimeArtifactHash|decisionConfigFingerprint|receiptHash)"\s*:\s*"<high-entropy>"/,
|
|
7
|
+
/"approvalId"\s*:\s*"<approval-id>"/,
|
|
8
|
+
];
|
|
9
|
+
const AUDIT_SCAN_CHUNK_BYTES = 64 * 1024;
|
|
10
|
+
const AUDIT_SCAN_OVERLAP_CHARS = 256;
|
|
11
|
+
export function auditLogHasLegacyScrubPlaceholders(sample) {
|
|
12
|
+
return LEGACY_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(sample));
|
|
13
|
+
}
|
|
14
|
+
export function auditRecordHasLegacyCorrelationPlaceholders(record) {
|
|
15
|
+
return (record.timestamp === '<timestamp>' ||
|
|
16
|
+
record.fingerprint === '<high-entropy>' ||
|
|
17
|
+
record.approvalId === '<approval-id>');
|
|
18
|
+
}
|
|
19
|
+
async function auditFileHasLegacyScrubPlaceholders(auditPath) {
|
|
20
|
+
const handle = await open(auditPath, 'r');
|
|
21
|
+
const buffer = Buffer.allocUnsafe(AUDIT_SCAN_CHUNK_BYTES);
|
|
22
|
+
let carry = '';
|
|
23
|
+
let position = 0;
|
|
24
|
+
try {
|
|
25
|
+
while (true) {
|
|
26
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
27
|
+
if (bytesRead === 0) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const sample = carry + buffer.toString('utf8', 0, bytesRead);
|
|
31
|
+
if (auditLogHasLegacyScrubPlaceholders(sample)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
carry = sample.slice(-AUDIT_SCAN_OVERLAP_CHARS);
|
|
35
|
+
position += bytesRead;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await handle.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function archiveLegacyAuditLogIfNeeded(repoRoot, config) {
|
|
43
|
+
const auditPath = path.isAbsolute(config.audit.logPath)
|
|
44
|
+
? config.audit.logPath
|
|
45
|
+
: path.join(repoRoot, config.audit.logPath);
|
|
46
|
+
if (!existsSync(auditPath)) {
|
|
47
|
+
return { archived: false };
|
|
48
|
+
}
|
|
49
|
+
if (!(await auditFileHasLegacyScrubPlaceholders(auditPath))) {
|
|
50
|
+
return { archived: false };
|
|
51
|
+
}
|
|
52
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
53
|
+
const archivePath = `${auditPath}.legacy-${timestamp}.ndjson`;
|
|
54
|
+
await rename(auditPath, archivePath);
|
|
55
|
+
return { archived: true, archivedPath: archivePath };
|
|
56
|
+
}
|
|
@@ -4,6 +4,10 @@ import type { AvailabilityAskCounts, ReasonApprovalRatio, RepeatedFingerprintAsk
|
|
|
4
4
|
/** Minimum gate events before recommending enforce with zero would-block rate. */
|
|
5
5
|
export declare const MIN_GATE_EVENTS_FOR_ENFORCE = 20;
|
|
6
6
|
export interface AuditCohortIdentity {
|
|
7
|
+
runtimeArtifactHash: string;
|
|
8
|
+
decisionConfigFingerprint: string;
|
|
9
|
+
boundaryProfile: string;
|
|
10
|
+
/** Display / forensics metadata — not used for v3 cohort matching when artifact hash is present. */
|
|
7
11
|
runtimeBuildStamp: string;
|
|
8
12
|
configFingerprint: string;
|
|
9
13
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesAuditCohort } from '../runtime-provenance.js';
|
|
1
2
|
import { bucketGateEventsByDay, computeApprovalLatencyStats, computeApprovalRatioByReason, computeAvailabilityAskCounts, computeRepeatedFingerprintAsks, computeWouldBlockByReason, countVerdicts, detectBypassAttempts, detectNoisyRules, } from './audit-analysis.js';
|
|
2
3
|
import { buildApprovalRoundTrips, filterAuditRecords, inferWouldBlock, isApprovalRecorded, toAuditRecord, } from './audit-query.js';
|
|
3
4
|
import { computeRecoveryMetrics, } from './audit-recovery-metrics.js';
|
|
@@ -106,8 +107,7 @@ export function computeAuditMetrics(records, options = {}) {
|
|
|
106
107
|
.slice(0, 10);
|
|
107
108
|
const activeCohort = options.activeCohort ?? null;
|
|
108
109
|
const cohortRecords = activeCohort
|
|
109
|
-
? auditRecords.filter((record) => record
|
|
110
|
-
record.configFingerprint === activeCohort.configFingerprint)
|
|
110
|
+
? auditRecords.filter((record) => matchesAuditCohort(record, activeCohort))
|
|
111
111
|
: [];
|
|
112
112
|
const cohortGateRecords = cohortRecords.filter((record) => {
|
|
113
113
|
const event = typeof record.event === 'string' ? record.event : '';
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ApprovalRoundTrip, AuditFilter, AuditRecord } from './audit-types.js';
|
|
2
2
|
export declare function toAuditRecord(value: Record<string, unknown>): AuditRecord;
|
|
3
3
|
export declare function parseTimestamp(value?: string): number | null;
|
|
4
|
+
export declare function auditFingerprint(record: AuditRecord): string | undefined;
|
|
5
|
+
export declare function auditApprovalCorrelationId(record: AuditRecord): string | undefined;
|
|
4
6
|
export declare function isGateRecord(record: AuditRecord): boolean;
|
|
5
7
|
export declare function isShellGateRecord(record: AuditRecord): boolean;
|
|
6
8
|
export declare function isApprovalRecorded(record: AuditRecord): boolean;
|
package/dist/core/audit-query.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValidApprovalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, } from './audit-serialize.js';
|
|
1
2
|
import { GATE_EVENTS } from './audit-types.js';
|
|
2
3
|
export function toAuditRecord(value) {
|
|
3
4
|
const record = { ...value };
|
|
@@ -7,12 +8,25 @@ export function toAuditRecord(value) {
|
|
|
7
8
|
return record;
|
|
8
9
|
}
|
|
9
10
|
export function parseTimestamp(value) {
|
|
10
|
-
if (!value) {
|
|
11
|
+
if (!value || !isValidAuditTimestamp(value)) {
|
|
11
12
|
return null;
|
|
12
13
|
}
|
|
13
14
|
const parsed = Date.parse(value);
|
|
14
15
|
return Number.isNaN(parsed) ? null : parsed;
|
|
15
16
|
}
|
|
17
|
+
export function auditFingerprint(record) {
|
|
18
|
+
if (typeof record.fingerprint !== 'string' || !isValidAuditFingerprint(record.fingerprint)) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return record.fingerprint;
|
|
22
|
+
}
|
|
23
|
+
export function auditApprovalCorrelationId(record) {
|
|
24
|
+
if (typeof record.approvalCorrelationId === 'string' &&
|
|
25
|
+
isValidApprovalCorrelationId(record.approvalCorrelationId)) {
|
|
26
|
+
return record.approvalCorrelationId;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
16
30
|
export function isGateRecord(record) {
|
|
17
31
|
return typeof record.event === 'string' && GATE_EVENTS.has(record.event);
|
|
18
32
|
}
|
|
@@ -89,28 +103,36 @@ export function filterAuditRecords(records, filter = {}) {
|
|
|
89
103
|
}
|
|
90
104
|
export function buildApprovalRoundTrips(records) {
|
|
91
105
|
const trips = [];
|
|
106
|
+
const pendingByCorrelationId = new Map();
|
|
92
107
|
const pendingByApprovalId = new Map();
|
|
93
108
|
const pendingByFingerprint = new Map();
|
|
94
109
|
for (const record of records) {
|
|
95
110
|
const timestamp = record.timestamp ?? '';
|
|
96
|
-
|
|
111
|
+
const fingerprint = auditFingerprint(record);
|
|
112
|
+
const correlationId = auditApprovalCorrelationId(record);
|
|
113
|
+
if (isGateRecord(record) && inferWouldBlock(record) && fingerprint) {
|
|
97
114
|
const trip = {
|
|
98
115
|
denyTimestamp: timestamp,
|
|
99
|
-
fingerprint
|
|
116
|
+
fingerprint,
|
|
100
117
|
reason: record.reason ?? 'unknown',
|
|
101
118
|
summary: record.summary ?? '',
|
|
102
119
|
kind: record.kind ?? 'unknown',
|
|
103
120
|
approvalId: record.approvalId,
|
|
121
|
+
approvalCorrelationId: correlationId,
|
|
104
122
|
};
|
|
105
123
|
trips.push(trip);
|
|
106
|
-
if (
|
|
124
|
+
if (correlationId) {
|
|
125
|
+
pendingByCorrelationId.set(correlationId, trip);
|
|
126
|
+
}
|
|
127
|
+
if (record.approvalId && !String(record.approvalId).startsWith('<')) {
|
|
107
128
|
pendingByApprovalId.set(record.approvalId, trip);
|
|
108
129
|
}
|
|
109
|
-
pendingByFingerprint.set(
|
|
130
|
+
pendingByFingerprint.set(fingerprint, trip);
|
|
110
131
|
continue;
|
|
111
132
|
}
|
|
112
|
-
if (isApprovalRecorded(record)
|
|
113
|
-
const trip =
|
|
133
|
+
if (isApprovalRecorded(record)) {
|
|
134
|
+
const trip = (correlationId ? pendingByCorrelationId.get(correlationId) : undefined) ??
|
|
135
|
+
(record.approvalId ? pendingByApprovalId.get(record.approvalId) : undefined);
|
|
114
136
|
if (trip) {
|
|
115
137
|
trip.approvalTimestamp = timestamp;
|
|
116
138
|
const denyMs = parseTimestamp(trip.denyTimestamp);
|
|
@@ -123,9 +145,11 @@ export function buildApprovalRoundTrips(records) {
|
|
|
123
145
|
}
|
|
124
146
|
if (isGateRecord(record) &&
|
|
125
147
|
record.reason === 'approved_once' &&
|
|
126
|
-
|
|
148
|
+
fingerprint &&
|
|
127
149
|
record.permission === 'allow') {
|
|
128
|
-
const trip =
|
|
150
|
+
const trip = correlationId
|
|
151
|
+
? pendingByCorrelationId.get(correlationId)
|
|
152
|
+
: pendingByFingerprint.get(fingerprint);
|
|
129
153
|
if (trip) {
|
|
130
154
|
trip.executeTimestamp = timestamp;
|
|
131
155
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesAuditCohort } from '../runtime-provenance.js';
|
|
1
2
|
import { GATE_EVENTS } from './audit-types.js';
|
|
2
3
|
const RESTORE_EVENTS = new Set(['recoveryApplied', 'recoveryConflict', 'recoveryRejected']);
|
|
3
4
|
const STABLE_FAILURE_REASON = /^[a-z][a-z0-9_]*$/;
|
|
@@ -35,8 +36,7 @@ function isRecoveryRestoreRecord(record) {
|
|
|
35
36
|
return RESTORE_EVENTS.has(event);
|
|
36
37
|
}
|
|
37
38
|
function matchesCohort(record, cohort) {
|
|
38
|
-
return (record
|
|
39
|
-
record.configFingerprint === cohort.configFingerprint);
|
|
39
|
+
return matchesAuditCohort(record, cohort);
|
|
40
40
|
}
|
|
41
41
|
function emptySnapshotMetrics() {
|
|
42
42
|
return {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ScrubOptions } from './types.js';
|
|
2
|
+
export declare const AUDIT_SCHEMA_VERSION = 3;
|
|
3
|
+
export declare function approvalCorrelationId(approvalId: string): string;
|
|
4
|
+
export declare function isValidApprovalCorrelationId(value: string): boolean;
|
|
5
|
+
export declare function isValidAuditTimestamp(value: string): boolean;
|
|
6
|
+
export declare function isValidAuditFingerprint(value: string): boolean;
|
|
7
|
+
export declare function serializeAuditRecordV3(record: Record<string, unknown>, options: ScrubOptions): Record<string, unknown>;
|
|
8
|
+
export declare function parseAuditNdjsonLine(line: string): Record<string, unknown> | null;
|
|
9
|
+
export declare function appendAuditRecord(auditPath: string, record: Record<string, unknown>, options: ScrubOptions): Promise<void>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { scrubString, scrubValue } from './scrub.js';
|
|
5
|
+
export const AUDIT_SCHEMA_VERSION = 3;
|
|
6
|
+
const ISO8601_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
7
|
+
const HEX64_PATTERN = /^[a-f0-9]{64}$/;
|
|
8
|
+
const SCRUB_PLACEHOLDERS = new Set(['<timestamp>', '<high-entropy>', '<approval-id>', '<uuid>']);
|
|
9
|
+
const PRESERVED_HASH_FIELDS = new Set([
|
|
10
|
+
'fingerprint',
|
|
11
|
+
'commandFingerprint',
|
|
12
|
+
'effectIRHash',
|
|
13
|
+
'payloadHash',
|
|
14
|
+
'configFingerprint',
|
|
15
|
+
'runtimeArtifactHash',
|
|
16
|
+
'decisionConfigFingerprint',
|
|
17
|
+
'receiptHash',
|
|
18
|
+
]);
|
|
19
|
+
const PRESERVED_LITERAL_FIELDS = new Set([
|
|
20
|
+
'timestamp',
|
|
21
|
+
'approvalCorrelationId',
|
|
22
|
+
'runtimeVersion',
|
|
23
|
+
'runtimeBuildStamp',
|
|
24
|
+
'boundaryProfile',
|
|
25
|
+
'schemaVersion',
|
|
26
|
+
'imageId',
|
|
27
|
+
'mirrorBackend',
|
|
28
|
+
'wouldMediate',
|
|
29
|
+
'exitCode',
|
|
30
|
+
'timedOut',
|
|
31
|
+
]);
|
|
32
|
+
const SCRUBBED_CONTAINER_FIELDS = new Set([
|
|
33
|
+
'summary',
|
|
34
|
+
'command',
|
|
35
|
+
'payload',
|
|
36
|
+
'replayContext',
|
|
37
|
+
'actionSnapshot',
|
|
38
|
+
'assessment',
|
|
39
|
+
'predictedAssessment',
|
|
40
|
+
'observedAssessment',
|
|
41
|
+
]);
|
|
42
|
+
export function approvalCorrelationId(approvalId) {
|
|
43
|
+
return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
|
|
44
|
+
}
|
|
45
|
+
export function isValidApprovalCorrelationId(value) {
|
|
46
|
+
return /^[a-f0-9]{16}$/.test(value);
|
|
47
|
+
}
|
|
48
|
+
export function isValidAuditTimestamp(value) {
|
|
49
|
+
if (SCRUB_PLACEHOLDERS.has(value) || !ISO8601_PATTERN.test(value)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return !Number.isNaN(Date.parse(value));
|
|
53
|
+
}
|
|
54
|
+
export function isValidAuditFingerprint(value) {
|
|
55
|
+
return HEX64_PATTERN.test(value) && !SCRUB_PLACEHOLDERS.has(value);
|
|
56
|
+
}
|
|
57
|
+
function isValidPreservedHashField(field, value) {
|
|
58
|
+
if (field === 'receiptHash') {
|
|
59
|
+
return HEX64_PATTERN.test(value);
|
|
60
|
+
}
|
|
61
|
+
if (field === 'imageId') {
|
|
62
|
+
return /^sha256:[a-f0-9]{64}$/.test(value);
|
|
63
|
+
}
|
|
64
|
+
return isValidAuditFingerprint(value);
|
|
65
|
+
}
|
|
66
|
+
function scrubAuditContainer(value, options) {
|
|
67
|
+
return scrubValue(value, {
|
|
68
|
+
...options,
|
|
69
|
+
maskHighEntropyStrings: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function serializeAuditField(key, value, options) {
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
if (key === 'approvalId') {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
if (key === 'ts' && typeof value === 'string' && isValidAuditTimestamp(value)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
if (PRESERVED_LITERAL_FIELDS.has(key)) {
|
|
83
|
+
if (key === 'timestamp' && typeof value === 'string' && isValidAuditTimestamp(value)) {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
if (key === 'approvalCorrelationId' &&
|
|
87
|
+
typeof value === 'string' &&
|
|
88
|
+
isValidApprovalCorrelationId(value)) {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
if ((key === 'runtimeVersion' || key === 'runtimeBuildStamp' || key === 'boundaryProfile') &&
|
|
92
|
+
typeof value === 'string' &&
|
|
93
|
+
value.length > 0) {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
if (key === 'schemaVersion' && typeof value === 'number') {
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
if (key === 'mirrorBackend' && value === 'file_copy') {
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
if (key === 'wouldMediate' && value === true) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (key === 'exitCode' && (value === null || Number.isSafeInteger(value))) {
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
if (key === 'timedOut' && typeof value === 'boolean') {
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
if (key === 'imageId' && typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value)) {
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
if (PRESERVED_HASH_FIELDS.has(key) && typeof value === 'string') {
|
|
117
|
+
return isValidPreservedHashField(key, value) ? value : undefined;
|
|
118
|
+
}
|
|
119
|
+
if (SCRUBBED_CONTAINER_FIELDS.has(key)) {
|
|
120
|
+
return scrubAuditContainer(value, options);
|
|
121
|
+
}
|
|
122
|
+
if (typeof value === 'string') {
|
|
123
|
+
return scrubString(value, { ...options, maskHighEntropyStrings: true });
|
|
124
|
+
}
|
|
125
|
+
if (value !== null && typeof value === 'object') {
|
|
126
|
+
return scrubAuditContainer(value, options);
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
export function serializeAuditRecordV3(record, options) {
|
|
131
|
+
const timestamp = typeof record.timestamp === 'string' && isValidAuditTimestamp(record.timestamp)
|
|
132
|
+
? record.timestamp
|
|
133
|
+
: typeof record.ts === 'string' && isValidAuditTimestamp(record.ts)
|
|
134
|
+
? record.ts
|
|
135
|
+
: new Date().toISOString();
|
|
136
|
+
const serialized = {
|
|
137
|
+
schemaVersion: AUDIT_SCHEMA_VERSION,
|
|
138
|
+
timestamp,
|
|
139
|
+
};
|
|
140
|
+
if (typeof record.approvalId === 'string' && record.approvalId.length > 0) {
|
|
141
|
+
serialized.approvalCorrelationId = approvalCorrelationId(record.approvalId);
|
|
142
|
+
}
|
|
143
|
+
else if (typeof record.approvalCorrelationId === 'string' &&
|
|
144
|
+
isValidApprovalCorrelationId(record.approvalCorrelationId)) {
|
|
145
|
+
serialized.approvalCorrelationId = record.approvalCorrelationId;
|
|
146
|
+
}
|
|
147
|
+
for (const [key, value] of Object.entries(record)) {
|
|
148
|
+
if (key === 'timestamp' || key === 'ts' || key === 'approvalId' || key === 'schemaVersion') {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const next = serializeAuditField(key, value, options);
|
|
152
|
+
if (next !== undefined) {
|
|
153
|
+
serialized[key] = next;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return serialized;
|
|
157
|
+
}
|
|
158
|
+
export function parseAuditNdjsonLine(line) {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
if (!trimmed) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(trimmed);
|
|
165
|
+
if (typeof parsed.ts === 'string' && parsed.timestamp === undefined) {
|
|
166
|
+
parsed.timestamp = parsed.ts;
|
|
167
|
+
delete parsed.ts;
|
|
168
|
+
}
|
|
169
|
+
return parsed;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export async function appendAuditRecord(auditPath, record, options) {
|
|
176
|
+
await mkdir(path.dirname(auditPath), { recursive: true });
|
|
177
|
+
const line = JSON.stringify(serializeAuditRecordV3(record, options));
|
|
178
|
+
await appendFile(auditPath, `${line}\n`, 'utf8');
|
|
179
|
+
}
|
|
@@ -40,17 +40,11 @@ function effectPathIsLocal(target, context) {
|
|
|
40
40
|
const resolved = resolveCapabilityPath(target, context.cwd);
|
|
41
41
|
return (resolveWorkspaceRootMatch(context.repoRoot, [...(context.trustedWorkspaceRoots ?? [])], resolved) !== null);
|
|
42
42
|
}
|
|
43
|
-
function
|
|
43
|
+
function effectPathIsSecretOrCredentialPath(target, context) {
|
|
44
44
|
const resolved = resolveCapabilityPath(target, context.cwd);
|
|
45
45
|
if (HIGH_STAKES_EFFECT_PATHS.some((pattern) => pattern.test(resolved))) {
|
|
46
46
|
return true;
|
|
47
47
|
}
|
|
48
|
-
if (context.protectedArtifactRoots?.some((root) => pathWithinRoot(canonicalPath(root), resolved))) {
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
if (isGitMetadataPath(resolved, context.repoRoot)) {
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
48
|
const workspaceMatch = resolveWorkspaceRootMatch(context.repoRoot, [...(context.trustedWorkspaceRoots ?? [])], resolved);
|
|
55
49
|
if (!workspaceMatch) {
|
|
56
50
|
return false;
|
|
@@ -59,6 +53,24 @@ function effectPathIsHighStakes(target, context) {
|
|
|
59
53
|
...(context.sensitivePaths ?? []),
|
|
60
54
|
]);
|
|
61
55
|
}
|
|
56
|
+
function effectPathIsControlPlaneArtifactPath(target, context) {
|
|
57
|
+
const resolved = resolveCapabilityPath(target, context.cwd);
|
|
58
|
+
return (context.protectedArtifactRoots?.some((root) => pathWithinRoot(canonicalPath(root), resolved)) ??
|
|
59
|
+
false);
|
|
60
|
+
}
|
|
61
|
+
function effectPathIsHighStakes(target, context) {
|
|
62
|
+
if (effectPathIsSecretOrCredentialPath(target, context)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (effectPathIsControlPlaneArtifactPath(target, context)) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const resolved = resolveCapabilityPath(target, context.cwd);
|
|
69
|
+
if (isGitMetadataPath(resolved, context.repoRoot)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
62
74
|
/**
|
|
63
75
|
* Policy for a lowered shell effect. It intentionally ignores executable
|
|
64
76
|
* command names, segment heads, command text, and config allowlists.
|
|
@@ -115,12 +127,13 @@ export function evaluateEffectRequirementPolicy(requirement, context) {
|
|
|
115
127
|
}
|
|
116
128
|
switch (requirement.action) {
|
|
117
129
|
case 'fs.read':
|
|
118
|
-
if (requirement.resource.kind === 'path'
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
if (requirement.resource.kind === 'path') {
|
|
131
|
+
if (effectPathIsSecretOrCredentialPath(requirement.resource.path, context)) {
|
|
132
|
+
return effectDecision('ask', 'high_stakes_path', 'effect.fs_read_high_stakes', [
|
|
133
|
+
...signals,
|
|
134
|
+
'sensitive_path_read',
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
124
137
|
}
|
|
125
138
|
return effectDecision('allow', 'read_only', 'effect.fs_read', signals);
|
|
126
139
|
case 'fs.write':
|
|
@@ -130,6 +143,12 @@ export function evaluateEffectRequirementPolicy(requirement, context) {
|
|
|
130
143
|
if (requirement.resource.kind !== 'path') {
|
|
131
144
|
return effectDecision('ask', 'indeterminate_effect', 'effect.fs_write_unknown', signals);
|
|
132
145
|
}
|
|
146
|
+
if (effectPathIsControlPlaneArtifactPath(requirement.resource.path, context)) {
|
|
147
|
+
return effectDecision('ask', 'control_plane_mutation', 'effect.control_plane_write', [
|
|
148
|
+
...signals,
|
|
149
|
+
'control_plane_path',
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
133
152
|
if (effectPathIsHighStakes(requirement.resource.path, context)) {
|
|
134
153
|
return effectDecision('ask', 'high_stakes_path', 'effect.fs_write_high_stakes', [
|
|
135
154
|
...signals,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { canonicalStringify, hashValue } from './fingerprint.js';
|
|
2
|
+
/** Hash of config fields that affect authorization decisions (excludes mode, audit paths, judge, notifications). */
|
|
3
|
+
export function hashDecisionConfig(config) {
|
|
4
|
+
const { mode: _mode, notifications: _notifications, audit: _audit, judge: _judge, redaction: _redaction, installScope: _installScope, overrides: _overrides, tokenPrefix: _tokenPrefix, approvalTtlMinutes: _approvalTtlMinutes, ...decisionRelevant } = config;
|
|
5
|
+
return hashValue(canonicalStringify(decisionRelevant));
|
|
6
|
+
}
|