1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! forjar#372: strip everything a config can make a READ verb EXECUTE.
//!
//! # The promise that was not kept
//!
//! `src/verb/registry.rs` publishes EVERY verb with `Effects::ReadOnly`,
//! and `src/verb/spec.rs` says what that means: *safe for an agent to call
//! unattended*. `forjar mcp --schema` publishes the same bit as
//! `readOnlyHint: true`, and so does the running server — but only since
//! paiml/forjar#375. Before it, the annotation reached `--schema` and nothing
//! else: over real stdio every tool object came back as
//! `['description', 'inputSchema', 'name']`, so the one signal an agent is
//! supposed to consult before calling a tool without a human watching was
//! never delivered to it.
//!
//! `plan` did not keep it. Three ordinary config keys reach a subprocess from
//! inside `planner::plan`, with no flag involved and nothing to opt into:
//!
//! | config key | what runs | where |
//! |---|---|---|
//! | `ambient_inputs: [cmd]` | `bash -c cmd` | `core/task/ambient.rs` |
//! | `secrets.provider: sops` / `op` | `sops` / `op` | `core/resolver/template.rs` |
//! | `output_equivalence: !command cmd` | `bash -c cmd` | `core/task/output_hash.rs` |
//!
//! Measured over real `forjar mcp` stdio on 1.21.0, one verb per fresh
//! fixture: a config declaring `ambient_inputs: ["touch AMBIENT_FIRED; echo
//! v1"]` created the file when — and only when — `forjar_plan` was called. So
//! an agent asked to *inspect* an untrusted repository executed whatever that
//! repository declared.
//!
//! # Why not just call `plan` Mutating
//!
//! Because it is not. `plan` changes no machine and writes no lock; declaring
//! it `Mutating` would discard the one accurate signal an agent has about the
//! other eight verbs' neighbour, and would not stop the execution either.
//!
//! Instead the unattended surface plans over a config with those three keys
//! REMOVED, and says so in its output. That makes the plan explicitly
//! lock-relative for the parts it could not compute — the same disclosure
//! `forjar plan` already makes for its other blind spot (forjar#342, "run
//! `forjar drift` for what the machines actually hold").
//!
//! # What is deliberately NOT changed
//!
//! The CLI. `forjar plan` still probes, still runs `ambient_inputs`, still
//! shells out to `sops`. `ambient_inputs` (#244) is a good feature and the
//! operator who typed `forjar plan` chose their own config; the defect is a
//! surface that promises the opposite exposing it.
use crate;
/// The secrets provider substituted for any provider implemented as a
/// subprocess.
///
/// It must not silently become `env`: the `_ =>` arm of
/// `resolve_secret_with_provider` reads `FORJAR_SECRET_<KEY>`, so downgrading
/// `sops` to the default would resolve a DIFFERENT value under the same name
/// and hash it into the plan. This provider resolves to an error instead, which
/// `resolver::resolve_or_fallback` turns into "leave the resource unresolved" —
/// visible, and disclosed.
pub const NO_EXEC_SECRET_PROVIDER: &str = "unattended-no-exec";
/// True when a secrets provider is implemented by spawning a process.
///
/// `env` reads an environment variable and `file` reads a path; neither runs
/// anything a config author chose.
/// A config with every config-declared subprocess removed, plus one line per
/// thing removed.
///
/// The returned notes are the disclosure. An empty vector means the config
/// declared nothing this surface had to skip, so the unattended plan and the
/// CLI plan are computing the same thing.
/// Replace a subprocess secrets provider with the non-executing one.
/// Drop `ambient_inputs`, which are shell commands run to fingerprint the host.
/// Downgrade `output_equivalence: !command` to `none`.
///
/// `none` is the honest substitute: it means "this artifact's CONTENT does not
/// participate in the staleness hash", which is exactly true of an artifact
/// whose declared normaliser we refused to run. It is recorded distinctly from
/// `bytes` by `hash_outputs_with`, so the skip cannot alias with a real byte
/// comparison.
/// The prose disclosure for a set of skipped declarations.
///
/// `None` when nothing was skipped, so the field is absent exactly when the
/// unattended plan and the CLI plan agree — the same biconditional
/// `scope_disclosure` uses for forjar#342.
/// Join the two plan disclosures into the one string a consumer reads.
///
/// forjar#342 named what the plan did not CONSULT; this file names what it did
/// not EXECUTE. A consumer that reads only `disclosure` must learn both, so
/// they compose rather than overwrite.