watchmyagents 1.1.3 → 1.1.4
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/package.json +1 -1
- package/scripts/shield.js +17 -30
- package/src/shield/policy-stream.js +10 -1
- package/src/shield/policy.js +42 -9
- package/src/shield/sse.js +48 -0
- package/src/shield/stream.js +11 -2
- package/src/shield/upload.js +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
package/scripts/shield.js
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
// ANTHROPIC_API_KEY env var is used if --api-key is omitted.
|
|
26
26
|
|
|
27
27
|
import { resolve } from 'node:path';
|
|
28
|
-
import { createHash } from 'node:crypto';
|
|
29
28
|
import { streamWithReconnect } from '../src/shield/stream.js';
|
|
30
29
|
import { loadPolicies, evaluate } from '../src/shield/policy.js';
|
|
31
30
|
import {
|
|
@@ -39,6 +38,12 @@ import { FortressPolicySource, postDecision } from '../src/shield/sources/fortre
|
|
|
39
38
|
import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
|
|
40
39
|
import { PolicyStream } from '../src/shield/policy-stream.js';
|
|
41
40
|
import { isValidAgentId, isValidSessionId } from '../src/validate.js';
|
|
41
|
+
// v1.1.4 F-19 (P1 Codex audit): all egress to Fortress now flows through
|
|
42
|
+
// buildFortressDecisionPayload, which normalizes tool_name via the
|
|
43
|
+
// anonymizer's allowlist + salted-hash scheme and drops anything it can't
|
|
44
|
+
// safely normalize. Keeps Shield's payload aligned with the README
|
|
45
|
+
// promise that decisions ship fingerprints, not raw values.
|
|
46
|
+
import { buildFortressDecisionPayload } from '../src/shield/upload.js';
|
|
42
47
|
|
|
43
48
|
function parseArgs(argv) {
|
|
44
49
|
const out = {};
|
|
@@ -133,38 +138,20 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
133
138
|
// refreshes from Fortress (every 5 min) take effect without restart.
|
|
134
139
|
sinfo(sessionId, `attached (${mode} mode)`);
|
|
135
140
|
|
|
136
|
-
// Helper: hash an IoC value with the customer salt (same one used by
|
|
137
|
-
// anonymizer for signals → correlates decisions to signals in Fortress).
|
|
138
|
-
// Returns null if no salt is configured (decisions still upload, just
|
|
139
|
-
// without input_hash).
|
|
140
|
-
const hashIoc = (value) => {
|
|
141
|
-
if (!signalsSalt || value == null) return null;
|
|
142
|
-
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
143
|
-
return 'sha256:' + createHash('sha256').update(signalsSalt).update(s).digest('hex').slice(0, 32);
|
|
144
|
-
};
|
|
145
|
-
|
|
146
141
|
// Helper: assemble + fire the decision push to Fortress (fire-and-forget).
|
|
142
|
+
// v1.1.4 F-19 (P1 Codex audit): delegates payload construction to the
|
|
143
|
+
// pure helper in src/shield/upload.js so the egress-side containment
|
|
144
|
+
// logic (tool_name allowlist + salted hashing) is unit-tested in
|
|
145
|
+
// isolation. The helper drops any field it cannot safely normalize
|
|
146
|
+
// (custom tool without salt, missing salt for hashes) rather than
|
|
147
|
+
// passing the raw value through.
|
|
147
148
|
const fireToFortress = (rawEvent, normalized, result, decidedInMs) => {
|
|
148
149
|
if (!pushDecisionToFortress) return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
pushDecisionToFortress({
|
|
156
|
-
anthropic_agent_id: agentId,
|
|
157
|
-
decision: result.decision,
|
|
158
|
-
rule_id: result.rule_id || undefined,
|
|
159
|
-
session_hash: hashIoc(sessionId) || undefined,
|
|
160
|
-
event_id_hash: hashIoc(rawEvent?.id) || undefined,
|
|
161
|
-
input_hash: hashIoc(inputForHash) || undefined,
|
|
162
|
-
action_type: normalized?.action_type || undefined,
|
|
163
|
-
tool_name: normalized?.tool_name || undefined,
|
|
164
|
-
message: result.message || result.rule_name || undefined,
|
|
165
|
-
decided_at: new Date().toISOString(),
|
|
166
|
-
decided_in_ms: decidedInMs,
|
|
167
|
-
}).catch(() => undefined);
|
|
150
|
+
const payload = buildFortressDecisionPayload({
|
|
151
|
+
agentId, sessionId, rawEvent, normalized, result, decidedInMs,
|
|
152
|
+
signalsSalt,
|
|
153
|
+
});
|
|
154
|
+
pushDecisionToFortress(payload).catch(() => undefined);
|
|
168
155
|
};
|
|
169
156
|
|
|
170
157
|
let processed = 0, enforced = 0, sessionInterrupted = false;
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { request as httpsRequest } from 'node:https';
|
|
33
33
|
import { URL } from 'node:url';
|
|
34
34
|
import { EventEmitter } from 'node:events';
|
|
35
|
+
import { normalizeSseBuffer } from './sse.js';
|
|
35
36
|
|
|
36
37
|
const RECONNECT_MIN_MS = 1_000;
|
|
37
38
|
const RECONNECT_MAX_MS = 60_000;
|
|
@@ -154,6 +155,13 @@ export class PolicyStream extends EventEmitter {
|
|
|
154
155
|
let buffer = '';
|
|
155
156
|
res.on('data', (chunk) => {
|
|
156
157
|
buffer += chunk;
|
|
158
|
+
// v1.1.4 F-18 (P1 Codex audit): normalize CR / CRLF line
|
|
159
|
+
// terminators to LF before scanning for the event separator.
|
|
160
|
+
// Without this, a Fortress deployment behind a reverse-proxy
|
|
161
|
+
// that emits CRLF would never trigger a policy refresh push —
|
|
162
|
+
// updates would silently fall back to the 60s polling loop,
|
|
163
|
+
// breaking the "sub-second propagation" promise.
|
|
164
|
+
buffer = normalizeSseBuffer(buffer);
|
|
157
165
|
// v1.1.1 F-9: cap on a single SSE event buffer. A buggy/compromised
|
|
158
166
|
// endpoint that never emits "\n\n" would otherwise OOM Shield.
|
|
159
167
|
// Abort + reconnect on overflow; the buffer is dropped so we
|
|
@@ -165,7 +173,8 @@ export class PolicyStream extends EventEmitter {
|
|
|
165
173
|
if (!this._closed) this._scheduleReconnect();
|
|
166
174
|
return;
|
|
167
175
|
}
|
|
168
|
-
// SSE events are separated by a blank line
|
|
176
|
+
// SSE events are separated by a blank line. Post-normalize the
|
|
177
|
+
// canonical separator is "\n\n".
|
|
169
178
|
let eolIdx;
|
|
170
179
|
while ((eolIdx = buffer.indexOf('\n\n')) !== -1) {
|
|
171
180
|
const rawEvent = buffer.slice(0, eolIdx);
|
package/src/shield/policy.js
CHANGED
|
@@ -68,28 +68,61 @@ export async function loadPolicies(path) {
|
|
|
68
68
|
return data;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// ReDoS protection: regexes are loaded from a user-provided JSON policy file
|
|
72
|
-
//
|
|
73
|
-
//
|
|
71
|
+
// ReDoS protection: regexes are loaded from a user-provided JSON policy file
|
|
72
|
+
// (LOCAL adapter) AND from Fortress / Guardian-generated rules (FORTRESS
|
|
73
|
+
// adapter), so a malicious or buggy pattern (e.g. `(a+)+$`) could pin
|
|
74
|
+
// Shield's CPU on a long input — taking the whole enforcement loop down.
|
|
75
|
+
// We mitigate three ways:
|
|
74
76
|
// 1) Cap the maximum input length passed to any regex test to MAX_REGEX_INPUT
|
|
75
77
|
// bytes. Above that we truncate before testing. Real agent values
|
|
76
|
-
// (URLs, commands, queries) are well under this in practice.
|
|
77
|
-
// 2) Reject obviously dangerous patterns at compile time (heuristic
|
|
78
|
+
// (URLs, commands, queries, file paths) are well under this in practice.
|
|
79
|
+
// 2) Reject obviously dangerous patterns at compile time (heuristic — see
|
|
80
|
+
// SUSPICIOUS_REGEX_PATTERNS below). The list errs toward false positives
|
|
81
|
+
// because Shield runs in the hot path: a rejected rule is loud (the
|
|
82
|
+
// rule is dropped at load time with a clear error) while a runaway
|
|
83
|
+
// regex would silently degrade Shield to "no enforcement" until the
|
|
84
|
+
// operator notices a CPU spike.
|
|
85
|
+
// 3) Hard upper bound on the regex source length so a deeply nested
|
|
86
|
+
// pattern can't game the heuristic by spreading the gadget across
|
|
87
|
+
// thousands of chars.
|
|
78
88
|
//
|
|
79
|
-
//
|
|
80
|
-
|
|
89
|
+
// Future work (v1.2+): proper RE2 or safe-regex-2 dependency for thorough
|
|
90
|
+
// analysis, or moving evaluation into a worker with a hard CPU timeout.
|
|
91
|
+
// We can't ship that today without breaking the zero-runtime-deps promise.
|
|
92
|
+
//
|
|
93
|
+
// v1.1.4 F-20 (P2 Codex audit): cap reduced from 8192 → 2048 (4×). Worst
|
|
94
|
+
// realistic IoC values (URL with long query string, base64 in a path)
|
|
95
|
+
// remain comfortably under 2048; the previous 8192 was a defence-in-depth
|
|
96
|
+
// holdover that no real workload exercises.
|
|
97
|
+
const MAX_REGEX_INPUT = 2048;
|
|
81
98
|
|
|
99
|
+
// v1.1.4 F-20: heuristic list extended to catch ambiguous alternation
|
|
100
|
+
// (`(a|a)*`, `(a|ab)+`, `(.|.)*`) — these don't trip the existing
|
|
101
|
+
// nested-quantifier heuristic but exhibit the same exponential behavior
|
|
102
|
+
// because every char has two paths to match. The new pattern rejects any
|
|
103
|
+
// alternation group immediately followed by `+` or `*`; this is intentionally
|
|
104
|
+
// over-broad — a customer who needs `(http|https):` should use either a
|
|
105
|
+
// character class (`[a-z]+:`) or move the optional letter (`https?:`).
|
|
82
106
|
const SUSPICIOUS_REGEX_PATTERNS = [
|
|
83
107
|
/(\([^)]*[+*][^)]*\))[+*]/, // (x+)+ or (x*)* — classic catastrophic backtracking
|
|
84
108
|
/(\.\*){3,}/, // multiple .* in a row
|
|
109
|
+
// F-20: alternation inside a group, then `+` or `*` — `(a|a)*`, `(a|ab)+`,
|
|
110
|
+
// `(.|.)*`. The `[^)]*\|[^)]*` body requires at least one `|` inside the
|
|
111
|
+
// group; a single-branch group like `(foo)+` is NOT matched.
|
|
112
|
+
/\([^)]*\|[^)]*\)[+*]/,
|
|
85
113
|
];
|
|
86
114
|
|
|
87
115
|
export function validateRegexString(src, where) {
|
|
88
116
|
if (typeof src !== 'string') {
|
|
89
117
|
throw new Error(`policy ${where}: regex must be a string`);
|
|
90
118
|
}
|
|
91
|
-
|
|
92
|
-
|
|
119
|
+
// v1.1.4 F-20: cap regex source at 1024 chars (was 2000). Real Shield
|
|
120
|
+
// policies are short (URL-prefix match, host-list deny, command-prefix
|
|
121
|
+
// check). A 1KB regex source is already an outlier; anything longer is
|
|
122
|
+
// either pathological or trying to bypass the heuristics by smuggling
|
|
123
|
+
// a gadget across many bytes.
|
|
124
|
+
if (src.length > 1024) {
|
|
125
|
+
throw new Error(`policy ${where}: regex too long (>1024 chars)`);
|
|
93
126
|
}
|
|
94
127
|
for (const sus of SUSPICIOUS_REGEX_PATTERNS) {
|
|
95
128
|
if (sus.test(src)) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SSE line terminator normalization — v1.1.4 F-18 (P1 Codex audit).
|
|
2
|
+
//
|
|
3
|
+
// The HTML Living Standard's event-stream parsing rules (whatwg SSE)
|
|
4
|
+
// accept three line terminator forms: LF (\n), CR (\r), and CRLF (\r\n).
|
|
5
|
+
// An event ends at a blank line: TWO consecutive line terminators of any
|
|
6
|
+
// of those forms, possibly mixed (e.g. \r\n\r\n, \r\r, \n\n, \r\n\n).
|
|
7
|
+
//
|
|
8
|
+
// Before this fix, Shield's two SSE consumers (stream.js for live agent
|
|
9
|
+
// events, policy-stream.js for Fortress policy push) only looked for
|
|
10
|
+
// the LF-LF separator. An upstream proxy or endpoint that emitted CRLF
|
|
11
|
+
// (most production-grade reverse-proxies do, by default!) would yield
|
|
12
|
+
// a buffer that never matched \n\n — Shield would silently never see
|
|
13
|
+
// the events:
|
|
14
|
+
// - agent-stream side: no deny/interrupt would fire live, breaking
|
|
15
|
+
// the sub-second enforcement promise
|
|
16
|
+
// - policy-stream side: updates would fall back to the 60s polling
|
|
17
|
+
// loop, making rule rollouts visibly slow
|
|
18
|
+
//
|
|
19
|
+
// Fix: normalize the buffer to LF-only before scanning. The normalize
|
|
20
|
+
// step is chunk-safe: a CR at the very end of the current buffer is
|
|
21
|
+
// preserved verbatim (it might be the first half of an incoming CRLF
|
|
22
|
+
// on the next chunk). Once a CR is no longer trailing, it's converted.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize SSE line terminators in a streaming buffer to LF.
|
|
26
|
+
*
|
|
27
|
+
* Use as: `buffer = normalizeSseBuffer(buffer + newChunk);`
|
|
28
|
+
*
|
|
29
|
+
* Guarantees, after the call:
|
|
30
|
+
* - every `\r\n` pair has been replaced by `\n`
|
|
31
|
+
* - every bare `\r` NOT at the very end has been replaced by `\n`
|
|
32
|
+
* - a trailing `\r` is preserved verbatim so the next iteration can
|
|
33
|
+
* check whether it was actually the first half of a CRLF
|
|
34
|
+
*
|
|
35
|
+
* @param {string} buffer the streaming buffer (already concatenated)
|
|
36
|
+
* @returns {string} buffer with line terminators normalized to LF
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeSseBuffer(buffer) {
|
|
39
|
+
if (typeof buffer !== 'string' || buffer.length === 0) return buffer;
|
|
40
|
+
// Defer the very last character if it's a CR — we don't know yet
|
|
41
|
+
// whether it's a bare CR terminator or the first half of CRLF.
|
|
42
|
+
const tail = buffer.endsWith('\r') ? '\r' : '';
|
|
43
|
+
const scannable = tail ? buffer.slice(0, -1) : buffer;
|
|
44
|
+
// Two-pass replace: CRLF first (so we don't double-convert the LF
|
|
45
|
+
// half), then any remaining bare CR.
|
|
46
|
+
const normalized = scannable.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
47
|
+
return tail ? normalized + tail : normalized;
|
|
48
|
+
}
|
package/src/shield/stream.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Uses built-in fetch + ReadableStream (Node 18+). Zero deps.
|
|
8
8
|
|
|
9
|
+
import { normalizeSseBuffer } from './sse.js';
|
|
10
|
+
|
|
9
11
|
const API_BASE = 'https://api.anthropic.com';
|
|
10
12
|
const BETA = 'managed-agents-2026-04-01';
|
|
11
13
|
const VERSION = '2023-06-01';
|
|
@@ -51,6 +53,12 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
|
|
|
51
53
|
const { done, value } = await reader.read();
|
|
52
54
|
if (done) break;
|
|
53
55
|
buffer += decoder.decode(value, { stream: true });
|
|
56
|
+
// v1.1.4 F-18 (P1 Codex audit): normalize all SSE line terminators
|
|
57
|
+
// (CR, CRLF) to LF so the indexOf('\n\n') scan below catches every
|
|
58
|
+
// event boundary the spec allows. Without this, an upstream that
|
|
59
|
+
// emits CRLF (common in reverse-proxy paths) yielded a buffer that
|
|
60
|
+
// never matched and Shield silently lost the live enforcement loop.
|
|
61
|
+
buffer = normalizeSseBuffer(buffer);
|
|
54
62
|
|
|
55
63
|
// v1.1.2 F-16: guard against an upstream that never emits "\n\n" —
|
|
56
64
|
// throw to abort the stream cleanly, the caller's reconnect logic
|
|
@@ -60,8 +68,9 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
|
|
|
60
68
|
throw new Error(`SSE frame exceeded ${MAX_SSE_FRAME_BYTES} bytes — aborting stream (caller should reconnect)`);
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
// SSE frames are separated by a blank line
|
|
64
|
-
//
|
|
71
|
+
// SSE frames are separated by a blank line. Post-normalize, the
|
|
72
|
+
// canonical separator is "\n\n"; each frame may contain multiple
|
|
73
|
+
// lines; we only care about `data:` lines for now.
|
|
65
74
|
let nlIdx;
|
|
66
75
|
while ((nlIdx = buffer.indexOf('\n\n')) !== -1) {
|
|
67
76
|
const frame = buffer.slice(0, nlIdx);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Shield → Fortress decision payload builder.
|
|
2
|
+
// v1.1.4 F-19 (P1 Codex audit): pure helper extracted from scripts/shield.js
|
|
3
|
+
// so the egress-side containment can be unit-tested in isolation. The
|
|
4
|
+
// security invariant under test:
|
|
5
|
+
//
|
|
6
|
+
// nothing on this payload may carry raw customer identifiers — tool
|
|
7
|
+
// names, session ids, event ids, input values must either be in the
|
|
8
|
+
// documented allowlist (vendor built-ins) or salted-hashed.
|
|
9
|
+
//
|
|
10
|
+
// Anything that can't be safely normalized is DROPPED (set to undefined)
|
|
11
|
+
// rather than passed through. Decision still ships so Fortress can count
|
|
12
|
+
// it — only the leak-y field is omitted.
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { normalizeToolName } from '../anonymizer.js';
|
|
16
|
+
|
|
17
|
+
// Salted SHA-256 hash with the same 32-char truncation as the anonymizer
|
|
18
|
+
// and the rest of Shield's decision flow. Returns null for nullish input
|
|
19
|
+
// or when no salt is configured (fail-safe omission).
|
|
20
|
+
function hashWithSaltOpt(value, salt) {
|
|
21
|
+
if (value == null || !salt) return null;
|
|
22
|
+
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
23
|
+
return 'sha256:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract the most relevant input value to fingerprint (URL > command >
|
|
27
|
+
// query > path > file_path). The actual hashing is done downstream by
|
|
28
|
+
// hashIoc — this function only picks the IoC field.
|
|
29
|
+
function pickInputForHash(input) {
|
|
30
|
+
if (!input || typeof input !== 'object') return null;
|
|
31
|
+
return input.url || input.command || input.query || input.path || input.file_path || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the body POSTed to Fortress's ingest-decisions endpoint.
|
|
36
|
+
*
|
|
37
|
+
* Containment guarantees:
|
|
38
|
+
* - tool_name: vendor allowlist returned in clear; custom/MCP names
|
|
39
|
+
* return as "tool_hash:<32hex>" when a salt is configured; dropped
|
|
40
|
+
* entirely otherwise.
|
|
41
|
+
* - session_hash / event_id_hash / input_hash: salted SHA-256, omitted
|
|
42
|
+
* when no salt is configured.
|
|
43
|
+
* - Raw payload values (rawEvent.input, normalized.input) never
|
|
44
|
+
* appear on the wire — only their hashes do.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
* @param {string} opts.agentId - Anthropic agent id (already an opaque token)
|
|
48
|
+
* @param {string} opts.sessionId - Anthropic session id (hashed before egress)
|
|
49
|
+
* @param {object} opts.rawEvent - raw upstream event (id field is hashed)
|
|
50
|
+
* @param {object} opts.normalized - normalized event from normalizeForPolicy()
|
|
51
|
+
* @param {object} opts.result - policy evaluator output
|
|
52
|
+
* @param {number} opts.decidedInMs
|
|
53
|
+
* @param {string|null|undefined} opts.signalsSalt
|
|
54
|
+
* @param {string} [opts.decidedAtIso] - ISO 8601 timestamp; defaults to now()
|
|
55
|
+
* @returns {object} payload ready to POST to ingest-decisions
|
|
56
|
+
*/
|
|
57
|
+
export function buildFortressDecisionPayload({
|
|
58
|
+
agentId, sessionId, rawEvent, normalized, result, decidedInMs,
|
|
59
|
+
signalsSalt, decidedAtIso,
|
|
60
|
+
}) {
|
|
61
|
+
// F-19: vendor built-ins survive even without a salt (allowlist short-circuit);
|
|
62
|
+
// custom tool names throw without a salt — we catch and drop the field.
|
|
63
|
+
let safeToolName;
|
|
64
|
+
const rawToolName = normalized?.tool_name;
|
|
65
|
+
if (rawToolName) {
|
|
66
|
+
try {
|
|
67
|
+
safeToolName = normalizeToolName(rawToolName, signalsSalt);
|
|
68
|
+
} catch {
|
|
69
|
+
safeToolName = undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
anthropic_agent_id: agentId,
|
|
75
|
+
decision: result.decision,
|
|
76
|
+
rule_id: result.rule_id || undefined,
|
|
77
|
+
session_hash: hashWithSaltOpt(sessionId, signalsSalt) || undefined,
|
|
78
|
+
event_id_hash: hashWithSaltOpt(rawEvent?.id, signalsSalt) || undefined,
|
|
79
|
+
input_hash: hashWithSaltOpt(pickInputForHash(normalized?.input), signalsSalt) || undefined,
|
|
80
|
+
action_type: normalized?.action_type || undefined,
|
|
81
|
+
tool_name: safeToolName,
|
|
82
|
+
message: result.message || result.rule_name || undefined,
|
|
83
|
+
decided_at: decidedAtIso || new Date().toISOString(),
|
|
84
|
+
decided_in_ms: decidedInMs,
|
|
85
|
+
// v1.1.3 Phase 1.D: mode threading so Fortress can store and surface
|
|
86
|
+
// shadow-vs-enforce in the Reports timeline.
|
|
87
|
+
mode: result.mode || undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|